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,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