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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +82 -0
  5. data/SECURITY.md +40 -0
  6. data/docs/active-record-association-support.md +37 -0
  7. data/docs/assets/branding/rails-mmd-social-preview.jpg +0 -0
  8. data/docs/usage.md +126 -0
  9. data/exe/rails-mmd +6 -0
  10. data/fixtures/schemas/diagnostics/valid/catalog.json +39 -0
  11. data/lib/rails_mmd/artifact_ref_sanitizer.rb +59 -0
  12. data/lib/rails_mmd/attribute_types.rb +29 -0
  13. data/lib/rails_mmd/canonical_json.rb +19 -0
  14. data/lib/rails_mmd/cli.rb +86 -0
  15. data/lib/rails_mmd/config.rb +249 -0
  16. data/lib/rails_mmd/constant_resolver.rb +33 -0
  17. data/lib/rails_mmd/diagnostic_factory.rb +122 -0
  18. data/lib/rails_mmd/domain_resolver.rb +97 -0
  19. data/lib/rails_mmd/exit_policy.rb +27 -0
  20. data/lib/rails_mmd/generate_command.rb +116 -0
  21. data/lib/rails_mmd/generation_pipeline.rb +109 -0
  22. data/lib/rails_mmd/ir_builder.rb +122 -0
  23. data/lib/rails_mmd/mermaid_serializer.rb +168 -0
  24. data/lib/rails_mmd/metadata_shapes.rb +55 -0
  25. data/lib/rails_mmd/model_inventory.rb +144 -0
  26. data/lib/rails_mmd/ordering.rb +25 -0
  27. data/lib/rails_mmd/output_directory.rb +192 -0
  28. data/lib/rails_mmd/publisher.rb +166 -0
  29. data/lib/rails_mmd/rails_loader.rb +96 -0
  30. data/lib/rails_mmd/redactor.rb +143 -0
  31. data/lib/rails_mmd/relationship_builder.rb +982 -0
  32. data/lib/rails_mmd/render_plan_builder.rb +192 -0
  33. data/lib/rails_mmd/safe_token_assigner.rb +130 -0
  34. data/lib/rails_mmd/schema_probe.rb +376 -0
  35. data/lib/rails_mmd/schema_validator.rb +136 -0
  36. data/lib/rails_mmd/subject_ids.rb +17 -0
  37. data/lib/rails_mmd/tooling_tasks.rb +47 -0
  38. data/lib/rails_mmd/verification_runner.rb +143 -0
  39. data/lib/rails_mmd/version.rb +5 -0
  40. data/lib/rails_mmd.rb +15 -0
  41. data/schemas/config.schema.json +54 -0
  42. data/schemas/diagnostics.schema.json +418 -0
  43. data/schemas/ir.schema.json +84 -0
  44. data/schemas/render_plan.schema.json +136 -0
  45. 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