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,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/attribute_types'
|
|
4
|
+
require 'rails_mmd/canonical_json'
|
|
5
|
+
require 'rails_mmd/redactor'
|
|
6
|
+
require 'rails_mmd/safe_token_assigner'
|
|
7
|
+
|
|
8
|
+
module RailsMmd
|
|
9
|
+
# Projects normalized IR into renderer-only ER or class render plans.
|
|
10
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/ClassLength, Metrics/MethodLength, Metrics/ParameterLists, Naming/MethodParameterName, Style/MultilineBlockChain
|
|
11
|
+
class RenderPlanBuilder
|
|
12
|
+
DomainResult = Struct.new(:domain_id, :payload, :diagnostics, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
ER_MARKERS = {
|
|
15
|
+
'0..1' => ['|o', 'o|'],
|
|
16
|
+
'1..1' => ['||', '||'],
|
|
17
|
+
'0..many' => ['}o', 'o{'],
|
|
18
|
+
'1..many' => ['}|', '|{']
|
|
19
|
+
}.freeze
|
|
20
|
+
CLASS_MULTIPLICITIES = {
|
|
21
|
+
'0..1' => '0..1',
|
|
22
|
+
'1..1' => '1',
|
|
23
|
+
'0..many' => '0..*',
|
|
24
|
+
'1..many' => '1..*'
|
|
25
|
+
}.freeze
|
|
26
|
+
ASSOCIATION_LABEL_SUFFIX = /[?!=]\z/
|
|
27
|
+
|
|
28
|
+
def initialize(safe_tokens: SafeTokenAssigner.new, redactor: Redactor.new)
|
|
29
|
+
@safe_tokens = safe_tokens
|
|
30
|
+
@redactor = redactor
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build(ir:, artifact_kind:, direction:, attributes: :keys, comments: [], available_diagnostic_ids: [])
|
|
34
|
+
token_sets, diagnostics = token_sets_for(ir, artifact_kind, comments)
|
|
35
|
+
payload = payload_for(
|
|
36
|
+
ir: ir,
|
|
37
|
+
artifact_kind: artifact_kind,
|
|
38
|
+
direction: direction,
|
|
39
|
+
attributes: attributes.to_sym,
|
|
40
|
+
comments: comments,
|
|
41
|
+
token_sets: token_sets,
|
|
42
|
+
available_diagnostic_ids: available_diagnostic_ids
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
DomainResult.new(domain_id: ir.fetch('domain_id'), payload: payload, diagnostics: diagnostics)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :redactor, :safe_tokens
|
|
51
|
+
|
|
52
|
+
def payload_for(ir:, artifact_kind:, direction:, attributes:, comments:, token_sets:, available_diagnostic_ids:)
|
|
53
|
+
payload = {
|
|
54
|
+
'schema_version' => 1,
|
|
55
|
+
'artifact_kind' => artifact_kind,
|
|
56
|
+
'domain_id' => ir.fetch('domain_id'),
|
|
57
|
+
'direction' => direction,
|
|
58
|
+
'entities' => entities_payload(ir, attributes, token_sets),
|
|
59
|
+
'relationships' => relationships_payload(ir, token_sets),
|
|
60
|
+
'comments' => comments_payload(comments, token_sets),
|
|
61
|
+
'diagnostic_ids' => diagnostic_ids(ir, available_diagnostic_ids),
|
|
62
|
+
'digest_sha256' => nil
|
|
63
|
+
}
|
|
64
|
+
payload.merge('digest_sha256' => CanonicalJson.digest_sha256(payload))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def token_sets_for(ir, artifact_kind, comments)
|
|
68
|
+
token_sets = {}
|
|
69
|
+
diagnostics = []
|
|
70
|
+
token_subjects(ir, comments).each do |token_kind, subjects|
|
|
71
|
+
assignment = safe_tokens.assign(
|
|
72
|
+
subjects,
|
|
73
|
+
scope: { artifact_kind: artifact_kind, domain_id: ir.fetch('domain_id'), token_kind: token_kind }
|
|
74
|
+
)
|
|
75
|
+
token_sets[token_kind] = assignment.fetch(:tokens)
|
|
76
|
+
diagnostics.concat(assignment.fetch(:diagnostics))
|
|
77
|
+
end
|
|
78
|
+
[token_sets, diagnostics]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def token_subjects(ir, comments)
|
|
82
|
+
{
|
|
83
|
+
'entity' => ir.fetch('entities').map do |entity|
|
|
84
|
+
subject(entity.fetch('entity_id'), structured_mermaid_text(entity.fetch('ruby_constant'), :ruby_constant))
|
|
85
|
+
end,
|
|
86
|
+
'attribute' => ir.fetch('entities').flat_map { |entity| attribute_subjects(entity) },
|
|
87
|
+
'relationship' => ir.fetch('relationships').map do |relationship|
|
|
88
|
+
subject(relationship.fetch('relationship_id'),
|
|
89
|
+
relationship_label(relationship))
|
|
90
|
+
end,
|
|
91
|
+
'comment' => comments.map { |comment| subject(comment.fetch('comment_id'), comment.fetch('comment_id')) }
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def subject(identity, source)
|
|
96
|
+
{ identity: identity, source: source }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def attribute_subjects(entity)
|
|
100
|
+
entity.fetch('attributes').map do |attribute|
|
|
101
|
+
subject(attribute.fetch('attribute_id'), structured_mermaid_text(attribute.fetch('name'), :snake_identifier))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def entities_payload(ir, attributes, token_sets)
|
|
106
|
+
ir.fetch('entities').map do |entity|
|
|
107
|
+
{
|
|
108
|
+
'entity_id' => entity.fetch('entity_id'),
|
|
109
|
+
'safe_token' => token_sets.fetch('entity').fetch(entity.fetch('entity_id')),
|
|
110
|
+
'label' => structured_mermaid_text(entity.fetch('ruby_constant').split('::').last, :ruby_constant_label),
|
|
111
|
+
'attributes' => attributes == :none ? [] : attributes_payload(entity, token_sets)
|
|
112
|
+
}
|
|
113
|
+
end.sort_by { |entity| entity.fetch('entity_id') }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def attributes_payload(entity, token_sets)
|
|
117
|
+
entity.fetch('attributes').map do |attribute|
|
|
118
|
+
{
|
|
119
|
+
'attribute_id' => attribute.fetch('attribute_id'),
|
|
120
|
+
'safe_token' => token_sets.fetch('attribute').fetch(attribute.fetch('attribute_id')),
|
|
121
|
+
'label' => sanitize_attribute_name(attribute.fetch('name')),
|
|
122
|
+
'type' => AttributeTypes.normalize(attribute['type']),
|
|
123
|
+
'key_marker' => key_marker(attribute.fetch('role'))
|
|
124
|
+
}
|
|
125
|
+
end.sort_by { |attribute| [attribute.fetch('key_marker') == 'PK' ? 0 : 1, attribute.fetch('attribute_id')] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def relationships_payload(ir, token_sets)
|
|
129
|
+
ir.fetch('relationships').map do |relationship|
|
|
130
|
+
relationship_payload(relationship, token_sets)
|
|
131
|
+
end.sort_by { |relationship| relationship.fetch('relationship_id') }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def relationship_payload(relationship, token_sets)
|
|
135
|
+
er_left, = ER_MARKERS.fetch(relationship.fetch('owner_cardinality'))
|
|
136
|
+
_, er_right = ER_MARKERS.fetch(relationship.fetch('target_cardinality'))
|
|
137
|
+
{
|
|
138
|
+
'relationship_id' => relationship.fetch('relationship_id'),
|
|
139
|
+
'safe_token' => token_sets.fetch('relationship').fetch(relationship.fetch('relationship_id')),
|
|
140
|
+
'owner_safe_token' => token_sets.fetch('entity').fetch(relationship.fetch('owner_entity_id')),
|
|
141
|
+
'target_safe_token' => token_sets.fetch('entity').fetch(relationship.fetch('target_entity_id')),
|
|
142
|
+
'label' => relationship_label(relationship),
|
|
143
|
+
'owner_cardinality' => relationship.fetch('owner_cardinality'),
|
|
144
|
+
'target_cardinality' => relationship.fetch('target_cardinality'),
|
|
145
|
+
'er_left_marker' => er_left,
|
|
146
|
+
'er_right_marker' => er_right,
|
|
147
|
+
'class_owner_multiplicity' => CLASS_MULTIPLICITIES.fetch(relationship.fetch('owner_cardinality')),
|
|
148
|
+
'class_target_multiplicity' => CLASS_MULTIPLICITIES.fetch(relationship.fetch('target_cardinality'))
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def comments_payload(comments, token_sets)
|
|
153
|
+
comments.map do |comment|
|
|
154
|
+
{
|
|
155
|
+
'comment_id' => comment.fetch('comment_id'),
|
|
156
|
+
'safe_token' => token_sets.fetch('comment').fetch(comment.fetch('comment_id')),
|
|
157
|
+
'text' => sanitize_comment(comment.fetch('text'))
|
|
158
|
+
}
|
|
159
|
+
end.sort_by { |comment| comment.fetch('comment_id') }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def sanitize_comment(text)
|
|
163
|
+
free_text_mermaid_text(text)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def sanitize_attribute_name(name)
|
|
167
|
+
structured_mermaid_text(name, :snake_identifier)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def relationship_label(relationship)
|
|
171
|
+
structured_mermaid_text(relationship.fetch('association_name').to_s.sub(ASSOCIATION_LABEL_SUFFIX, ''),
|
|
172
|
+
:snake_identifier)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def free_text_mermaid_text(value)
|
|
176
|
+
Redactor.sanitize_mermaid_free_text(value, sanitizer: redactor)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def structured_mermaid_text(value, grammar)
|
|
180
|
+
Redactor.sanitize_mermaid_structured(value, grammar: grammar)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def diagnostic_ids(ir, available_diagnostic_ids)
|
|
184
|
+
allowed = available_diagnostic_ids.map(&:to_s)
|
|
185
|
+
ir.fetch('diagnostic_ids').select { |diagnostic_id| allowed.include?(diagnostic_id) }.sort
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def key_marker(role)
|
|
189
|
+
{ 'primary_key' => 'PK', 'foreign_key' => 'FK' }.fetch(role)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/canonical_json'
|
|
4
|
+
require 'rails_mmd/diagnostic_factory'
|
|
5
|
+
|
|
6
|
+
module RailsMmd
|
|
7
|
+
# Mermaid-safe token generation and scoped collision resolution.
|
|
8
|
+
# @api private
|
|
9
|
+
class SafeTokenAssigner
|
|
10
|
+
TOKEN_KINDS = %w[entity attribute relationship diagnostic comment].freeze
|
|
11
|
+
ARTIFACT_KINDS = %w[er class].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(diagnostics: DiagnosticFactory.new)
|
|
14
|
+
@diagnostic_factory = diagnostics
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def assign(subjects, scope:)
|
|
18
|
+
validate_scope!(scope)
|
|
19
|
+
subjects_by_base_token = subjects.group_by { |subject| base_token(subject.fetch(:source)) }
|
|
20
|
+
tokens_by_identity = subjects_by_base_token.filter_map do |base_token, subjects_for_token|
|
|
21
|
+
[subjects_for_token.first.fetch(:identity), base_token] if subjects_for_token.one?
|
|
22
|
+
end.to_h
|
|
23
|
+
collision_diagnostics = assign_token_groups(subjects_by_base_token, scope, tokens_by_identity)
|
|
24
|
+
|
|
25
|
+
{ tokens: tokens_by_identity, diagnostics: collision_diagnostics }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :diagnostic_factory
|
|
31
|
+
|
|
32
|
+
def base_token(source)
|
|
33
|
+
token = source.to_s.unicode_normalize(:nfkd)
|
|
34
|
+
token = token.gsub('::', '_')
|
|
35
|
+
token = token.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
|
|
36
|
+
token = token.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
|
|
37
|
+
token = token.gsub(/[^A-Za-z0-9]+/, '_')
|
|
38
|
+
token = token.delete('^A-Za-z0-9_')
|
|
39
|
+
token = token.squeeze('_').gsub(/\A_+|_+\z/, '').upcase
|
|
40
|
+
token = 'X' if token.empty?
|
|
41
|
+
token.match?(/\A\d/) ? "X_#{token}" : token
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_scope!(scope)
|
|
45
|
+
raise ArgumentError, 'invalid artifact_kind' unless ARTIFACT_KINDS.include?(scope.fetch(:artifact_kind))
|
|
46
|
+
raise ArgumentError, 'invalid token_kind' unless TOKEN_KINDS.include?(scope.fetch(:token_kind))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def assign_token_groups(subjects_by_base_token, scope, tokens_by_identity)
|
|
50
|
+
subjects_by_base_token.filter_map do |base_token, colliding_subjects|
|
|
51
|
+
next if colliding_subjects.one?
|
|
52
|
+
|
|
53
|
+
resolved_candidates = unique_collision_candidates(scope, base_token, colliding_subjects,
|
|
54
|
+
tokens_by_identity.values)
|
|
55
|
+
collision_resolved = !resolved_candidates.nil?
|
|
56
|
+
tokens_by_identity.merge!(resolved_candidates) if collision_resolved
|
|
57
|
+
collision_diagnostic(scope, base_token, colliding_subjects, resolved: collision_resolved)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def unique_collision_candidates(scope, base_token, colliding_subjects, reserved_tokens)
|
|
62
|
+
suffix_length = 12
|
|
63
|
+
loop do
|
|
64
|
+
candidates = collision_candidates(scope, base_token, colliding_subjects, suffix_length)
|
|
65
|
+
return candidates if unique_candidates?(candidates, reserved_tokens)
|
|
66
|
+
|
|
67
|
+
suffix_length += 4
|
|
68
|
+
break if suffix_length > 64
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def unique_candidates?(candidates, reserved_tokens)
|
|
75
|
+
candidates.values.uniq.length == candidates.length && !candidates.values.intersect?(reserved_tokens)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def collision_candidates(scope, base_token, colliding_subjects, suffix_length)
|
|
79
|
+
colliding_subjects.to_h do |subject|
|
|
80
|
+
[
|
|
81
|
+
subject.fetch(:identity),
|
|
82
|
+
"#{base_token}_H#{collision_digest(scope, base_token, subject)[0, suffix_length]}"
|
|
83
|
+
]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collision_digest(scope, base_token, subject)
|
|
88
|
+
payload = {
|
|
89
|
+
'scope' => {
|
|
90
|
+
'artifact_kind' => scope.fetch(:artifact_kind),
|
|
91
|
+
'domain_id' => scope.fetch(:domain_id),
|
|
92
|
+
'token_kind' => scope.fetch(:token_kind)
|
|
93
|
+
},
|
|
94
|
+
'subject_identity' => subject.fetch(:identity),
|
|
95
|
+
'base_safe_token' => base_token
|
|
96
|
+
}
|
|
97
|
+
CanonicalJson.digest_sha256(payload).upcase
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def collision_diagnostic(scope, base_token, colliding_subjects, resolved:)
|
|
101
|
+
diagnostic_factory.build(
|
|
102
|
+
code: 'SAFE_TOKEN_COLLISION',
|
|
103
|
+
subject_id: collision_subject_id(scope, base_token),
|
|
104
|
+
message: collision_message(base_token, resolved),
|
|
105
|
+
severity: resolved ? 'warning' : 'fatal',
|
|
106
|
+
metadata: collision_metadata(scope, base_token, colliding_subjects, resolved: resolved)
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def collision_subject_id(scope, base_token)
|
|
111
|
+
"#{scope.fetch(:artifact_kind)}:#{scope.fetch(:domain_id)}:#{scope.fetch(:token_kind)}:#{base_token}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def collision_metadata(scope, base_token, colliding_subjects, resolved:)
|
|
115
|
+
{
|
|
116
|
+
artifact_kind: scope.fetch(:artifact_kind),
|
|
117
|
+
domain_id: scope.fetch(:domain_id),
|
|
118
|
+
token_kind: scope.fetch(:token_kind),
|
|
119
|
+
base_safe_token: base_token,
|
|
120
|
+
collision_subject_count: colliding_subjects.length,
|
|
121
|
+
resolved: resolved
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def collision_message(base_token, resolved)
|
|
126
|
+
state = resolved ? 'resolved' : 'unresolved'
|
|
127
|
+
"Safe-token collision #{state} for #{base_token}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/constant_resolver'
|
|
4
|
+
require 'rails_mmd/diagnostic_factory'
|
|
5
|
+
require 'rails_mmd/redactor'
|
|
6
|
+
|
|
7
|
+
module RailsMmd
|
|
8
|
+
# Reads selected-domain-only schema metadata for resolved renderable records.
|
|
9
|
+
# rubocop:disable-next Metrics/ClassLength, Metrics/MethodLength
|
|
10
|
+
class SchemaProbe
|
|
11
|
+
DomainResult = Struct.new(:domain_id, :entities, :join_tables, :diagnostics, keyword_init: true)
|
|
12
|
+
Entity = Struct.new(
|
|
13
|
+
:ruby_constant,
|
|
14
|
+
:table_name,
|
|
15
|
+
:connection_context_id,
|
|
16
|
+
:columns,
|
|
17
|
+
:primary_key,
|
|
18
|
+
:foreign_keys,
|
|
19
|
+
:indexes,
|
|
20
|
+
keyword_init: true
|
|
21
|
+
)
|
|
22
|
+
Column = Struct.new(:name, :type, :nullable, keyword_init: true)
|
|
23
|
+
JoinTable = Struct.new(:table_name, :columns, :primary_key, keyword_init: true)
|
|
24
|
+
ForeignKey = Struct.new(:from_table, :column, :to_table, :primary_key, keyword_init: true)
|
|
25
|
+
Index = Struct.new(:columns, :unique, :where, :using, :expression, keyword_init: true)
|
|
26
|
+
# Carries valid metadata records when only some optional adapter rows degrade.
|
|
27
|
+
class PartialMetadataDegraded < StandardError
|
|
28
|
+
attr_reader :records
|
|
29
|
+
|
|
30
|
+
def initialize(records:, message:)
|
|
31
|
+
@records = records
|
|
32
|
+
super(message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
Result = Struct.new(:domains, :diagnostics, keyword_init: true)
|
|
36
|
+
|
|
37
|
+
def initialize(constant_resolver:, diagnostics: DiagnosticFactory.new, redactor: Redactor.new)
|
|
38
|
+
@constant_resolver = ConstantResolver.wrap(constant_resolver)
|
|
39
|
+
@diagnostic_factory = diagnostics
|
|
40
|
+
@redactor = redactor
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def probe(domains:)
|
|
44
|
+
domain_results = domains.map { |domain| probe_domain(domain) }
|
|
45
|
+
all_diagnostics = domain_results.flat_map(&:diagnostics)
|
|
46
|
+
|
|
47
|
+
Result.new(domains: domain_results, diagnostics: all_diagnostics)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :constant_resolver, :diagnostic_factory, :redactor
|
|
53
|
+
|
|
54
|
+
def probe_domain(domain)
|
|
55
|
+
connection_diagnostic = multi_db_diagnostic(domain)
|
|
56
|
+
return domain_result(domain, [], [connection_diagnostic]) if connection_diagnostic
|
|
57
|
+
|
|
58
|
+
entities = []
|
|
59
|
+
entity_models = []
|
|
60
|
+
output_diagnostics = []
|
|
61
|
+
domain.records.each do |record|
|
|
62
|
+
model = model_for(record)
|
|
63
|
+
entity, record_diagnostics = probe_record(domain.domain_id, record, model)
|
|
64
|
+
if entity
|
|
65
|
+
entities << entity
|
|
66
|
+
entity_models << model
|
|
67
|
+
end
|
|
68
|
+
output_diagnostics.concat(record_diagnostics)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
domain_result(domain, entities, output_diagnostics, probe_join_tables(entity_models))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def domain_result(domain, entities, output_diagnostics, join_tables = [])
|
|
75
|
+
DomainResult.new(
|
|
76
|
+
domain_id: domain.domain_id, entities: entities,
|
|
77
|
+
join_tables: join_tables, diagnostics: output_diagnostics
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def multi_db_diagnostic(domain)
|
|
82
|
+
raw_context_ids = domain.records.map(&:connection_context_id)
|
|
83
|
+
return if raw_context_ids.uniq.length < 2
|
|
84
|
+
|
|
85
|
+
connection_context_ids = raw_context_ids.map { |context_id| redactor.sanitize(context_id) }.sort
|
|
86
|
+
|
|
87
|
+
diagnostic_factory.build(
|
|
88
|
+
code: 'MULTI_DB_UNSUPPORTED',
|
|
89
|
+
message: "Domain #{domain.domain_id} selects multiple connection contexts",
|
|
90
|
+
subject_id: domain.domain_id,
|
|
91
|
+
metadata: { domain_id: domain.domain_id, connection_context_ids: connection_context_ids }
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def probe_record(domain_id, record, model)
|
|
96
|
+
return probe_record_failure(domain_id, record, :table) if model.nil?
|
|
97
|
+
|
|
98
|
+
return probe_record_failure(domain_id, record, :table) unless table_exists?(model)
|
|
99
|
+
|
|
100
|
+
columns = read_columns(model)
|
|
101
|
+
primary_key = read_primary_key(model)
|
|
102
|
+
return probe_record_failure(domain_id, record, :table) if columns.nil?
|
|
103
|
+
return probe_record_failure(domain_id, record, :primary_key) unless scalar_primary_key?(primary_key)
|
|
104
|
+
|
|
105
|
+
build_entity(domain_id, record, model, columns, primary_key)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def model_for(record)
|
|
109
|
+
constant_resolver.resolve(record.ruby_constant)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def probe_record_failure(domain_id, record, failure_kind)
|
|
113
|
+
diagnostic = if failure_kind == :primary_key
|
|
114
|
+
primary_key_unsupported(domain_id, record)
|
|
115
|
+
else
|
|
116
|
+
table_metadata_unavailable(domain_id, record, 'table metadata unavailable')
|
|
117
|
+
end
|
|
118
|
+
[nil, [diagnostic]]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_entity(domain_id, record, model, columns, primary_key)
|
|
122
|
+
foreign_keys, foreign_key_diagnostic = degradable_metadata(domain_id, record, 'foreign_key') do
|
|
123
|
+
read_foreign_keys(model, record.table_name)
|
|
124
|
+
end
|
|
125
|
+
indexes, index_diagnostic = degradable_metadata(domain_id, record, 'unique_index') do
|
|
126
|
+
read_indexes(model, record.table_name)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
[
|
|
130
|
+
Entity.new(
|
|
131
|
+
ruby_constant: record.ruby_constant,
|
|
132
|
+
table_name: record.table_name,
|
|
133
|
+
connection_context_id: record.connection_context_id,
|
|
134
|
+
columns: columns,
|
|
135
|
+
primary_key: primary_key,
|
|
136
|
+
foreign_keys: foreign_keys,
|
|
137
|
+
indexes: indexes
|
|
138
|
+
),
|
|
139
|
+
[foreign_key_diagnostic, index_diagnostic].compact
|
|
140
|
+
]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def table_exists?(model)
|
|
144
|
+
return true unless model.respond_to?(:table_exists?)
|
|
145
|
+
|
|
146
|
+
model.table_exists?
|
|
147
|
+
rescue LoadError, SyntaxError, StandardError
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def read_columns(model)
|
|
152
|
+
return [] unless model.respond_to?(:columns)
|
|
153
|
+
|
|
154
|
+
model.columns.map { |column| column_record(column) }
|
|
155
|
+
rescue LoadError, SyntaxError, StandardError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def probe_join_tables(models)
|
|
160
|
+
join_table_requests(models).group_by(&:last).filter_map do |table_name, requests|
|
|
161
|
+
requests.lazy.filter_map { |model, _name| read_join_table(model, table_name) }.first
|
|
162
|
+
end.sort_by(&:table_name)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def join_table_requests(models)
|
|
166
|
+
models.flat_map do |model|
|
|
167
|
+
habtm_reflections(model).filter_map do |reflection|
|
|
168
|
+
table_name = safe_value_from(reflection, :join_table)
|
|
169
|
+
[model, table_name] if table_name.is_a?(String) && !table_name.empty?
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def join_table_connection(model, table_name)
|
|
175
|
+
return unless model.respond_to?(:connection)
|
|
176
|
+
|
|
177
|
+
connection = model.connection
|
|
178
|
+
return unless connection.respond_to?(:data_source_exists?) && connection.data_source_exists?(table_name)
|
|
179
|
+
return unless connection.respond_to?(:columns) && connection.respond_to?(:primary_key)
|
|
180
|
+
|
|
181
|
+
connection
|
|
182
|
+
rescue LoadError, SyntaxError, StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def habtm_reflections(model)
|
|
187
|
+
return [] unless model.respond_to?(:reflect_on_all_associations)
|
|
188
|
+
|
|
189
|
+
Array(model.reflect_on_all_associations(:has_and_belongs_to_many))
|
|
190
|
+
rescue LoadError, SyntaxError, StandardError
|
|
191
|
+
[]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def read_join_table(model, table_name)
|
|
195
|
+
connection = join_table_connection(model, table_name)
|
|
196
|
+
return unless connection
|
|
197
|
+
|
|
198
|
+
JoinTable.new(
|
|
199
|
+
table_name: table_name,
|
|
200
|
+
columns: Array(connection.columns(table_name)).map { |column| column_record(column) },
|
|
201
|
+
primary_key: connection.primary_key(table_name)
|
|
202
|
+
)
|
|
203
|
+
rescue LoadError, SyntaxError, StandardError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def column_record(column)
|
|
208
|
+
Column.new(
|
|
209
|
+
name: value_from(column, :name),
|
|
210
|
+
type: value_from(column, :type),
|
|
211
|
+
nullable: value_from(column, :null)
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def safe_value_from(object, method_name)
|
|
216
|
+
value_from(object, method_name)
|
|
217
|
+
rescue LoadError, SyntaxError, StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def read_primary_key(model)
|
|
222
|
+
model.respond_to?(:primary_key) ? model.primary_key : nil
|
|
223
|
+
rescue LoadError, SyntaxError, StandardError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def scalar_primary_key?(primary_key)
|
|
228
|
+
primary_key.is_a?(String) && !primary_key.empty?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def degradable_metadata(domain_id, record, metadata_kind)
|
|
232
|
+
[yield, nil]
|
|
233
|
+
rescue PartialMetadataDegraded => e
|
|
234
|
+
[e.records, metadata_degraded(domain_id, record, metadata_kind, e.message)]
|
|
235
|
+
rescue LoadError, NotImplementedError, SyntaxError, StandardError => e
|
|
236
|
+
[[], metadata_degraded(domain_id, record, metadata_kind, e.message)]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def read_foreign_keys(model, table_name)
|
|
240
|
+
foreign_keys = if model.respond_to?(:foreign_keys)
|
|
241
|
+
model.foreign_keys
|
|
242
|
+
else
|
|
243
|
+
connection_metadata(model, :foreign_keys, table_name)
|
|
244
|
+
end
|
|
245
|
+
valid_records = valid_metadata_records(Array(foreign_keys)) { |foreign_key| foreign_key_record(foreign_key) }
|
|
246
|
+
return valid_records if valid_records.length == Array(foreign_keys).length
|
|
247
|
+
|
|
248
|
+
raise PartialMetadataDegraded.new(records: valid_records, message: 'foreign_key metadata inconsistent')
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def read_indexes(model, table_name)
|
|
252
|
+
indexes = if model.respond_to?(:indexes)
|
|
253
|
+
model.indexes
|
|
254
|
+
else
|
|
255
|
+
connection_metadata(model, :indexes, table_name)
|
|
256
|
+
end
|
|
257
|
+
column_names = model.columns.map { |column| value_from(column, :name) }
|
|
258
|
+
valid_records = valid_metadata_records(Array(indexes)) { |index| index_record(index, column_names) }
|
|
259
|
+
return valid_records if valid_records.length == Array(indexes).length
|
|
260
|
+
|
|
261
|
+
raise PartialMetadataDegraded.new(records: valid_records, message: 'unique_index metadata inconsistent')
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def valid_metadata_records(records)
|
|
265
|
+
records.filter_map do |record|
|
|
266
|
+
yield record
|
|
267
|
+
rescue ArgumentError
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def foreign_key_record(foreign_key)
|
|
273
|
+
record = ForeignKey.new(
|
|
274
|
+
from_table: value_from(foreign_key, :from_table),
|
|
275
|
+
column: value_from(foreign_key, :column),
|
|
276
|
+
to_table: value_from(foreign_key, :to_table),
|
|
277
|
+
primary_key: value_from(foreign_key, :primary_key)
|
|
278
|
+
)
|
|
279
|
+
return record if [record.from_table, record.column, record.to_table, record.primary_key].all? do |value|
|
|
280
|
+
value.is_a?(String) && !value.empty?
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
raise ArgumentError, 'foreign_key metadata inconsistent'
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def index_record(index, column_names)
|
|
287
|
+
record = Index.new(
|
|
288
|
+
columns: value_from(index, :columns),
|
|
289
|
+
unique: value_from(index, :unique),
|
|
290
|
+
where: value_from(index, :where),
|
|
291
|
+
using: normalized_index_using(value_from(index, :using)),
|
|
292
|
+
expression: value_from(index, :expression)
|
|
293
|
+
)
|
|
294
|
+
return record if index_record_valid?(record, column_names)
|
|
295
|
+
|
|
296
|
+
raise ArgumentError, 'unique_index metadata inconsistent'
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def index_record_valid?(record, column_names)
|
|
300
|
+
index_columns_valid?(record.columns, column_names) &&
|
|
301
|
+
boolean?(record.unique) &&
|
|
302
|
+
optional_index_fields_valid?(record)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def index_columns_valid?(columns, column_names)
|
|
306
|
+
columns.is_a?(Array) &&
|
|
307
|
+
columns.all? { |column| column.is_a?(String) && !column.empty? } &&
|
|
308
|
+
columns.all? { |column| column_names.include?(column) }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def boolean?(value)
|
|
312
|
+
[true, false].include?(value)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def optional_index_fields_valid?(record)
|
|
316
|
+
optional_string_or_nil?(record.where) &&
|
|
317
|
+
optional_string_or_nil?(record.expression) &&
|
|
318
|
+
optional_string_or_nil?(record.using)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def optional_string_or_nil?(value)
|
|
322
|
+
value.nil? || value.is_a?(String)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def normalized_index_using(value)
|
|
326
|
+
return value.to_s if value.is_a?(Symbol)
|
|
327
|
+
|
|
328
|
+
value
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def connection_metadata(model, method_name, table_name)
|
|
332
|
+
raise "#{method_name} metadata unavailable" unless model.respond_to?(:connection)
|
|
333
|
+
raise "#{method_name} metadata unavailable" unless model.connection.respond_to?(method_name)
|
|
334
|
+
|
|
335
|
+
model.connection.public_send(method_name, table_name)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def value_from(object, method_name)
|
|
339
|
+
return unless object.respond_to?(method_name)
|
|
340
|
+
|
|
341
|
+
object.public_send(method_name)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def table_metadata_unavailable(domain_id, record, reason)
|
|
345
|
+
diagnostic_factory.build(
|
|
346
|
+
code: 'MODEL_TABLE_MISSING',
|
|
347
|
+
message: "#{record.ruby_constant} table metadata is unavailable: #{reason}",
|
|
348
|
+
subject_id: "#{domain_id}:#{record.ruby_constant}",
|
|
349
|
+
metadata: { domain_id: domain_id, ruby_constant: record.ruby_constant, table_name: record.table_name }
|
|
350
|
+
)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def primary_key_unsupported(domain_id, record)
|
|
354
|
+
diagnostic_factory.build(
|
|
355
|
+
code: 'MODEL_PRIMARY_KEY_UNSUPPORTED',
|
|
356
|
+
message: "#{record.ruby_constant} primary key is unsupported",
|
|
357
|
+
subject_id: "#{domain_id}:#{record.ruby_constant}",
|
|
358
|
+
metadata: { domain_id: domain_id, ruby_constant: record.ruby_constant, table_name: record.table_name }
|
|
359
|
+
)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def metadata_degraded(domain_id, record, metadata_kind, reason)
|
|
363
|
+
diagnostic_factory.build(
|
|
364
|
+
code: 'DB_METADATA_DEGRADED',
|
|
365
|
+
message: "#{record.ruby_constant} #{metadata_kind} metadata is degraded",
|
|
366
|
+
subject_id: domain_id,
|
|
367
|
+
metadata: {
|
|
368
|
+
domain_id: domain_id,
|
|
369
|
+
ruby_constant: record.ruby_constant,
|
|
370
|
+
metadata_kind: metadata_kind,
|
|
371
|
+
reason: reason
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|