dbwatcher 1.0.0 → 1.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -210
  3. data/app/assets/config/dbwatcher_manifest.js +15 -0
  4. data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
  5. data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
  6. data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
  7. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
  8. data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
  9. data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
  10. data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
  11. data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
  12. data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
  13. data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
  14. data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
  15. data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
  16. data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
  17. data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
  18. data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
  19. data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
  20. data/app/assets/stylesheets/dbwatcher/application.css +423 -0
  21. data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
  22. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
  23. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
  24. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
  25. data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
  26. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
  27. data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
  28. data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
  29. data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
  30. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
  31. data/app/controllers/dbwatcher/base_controller.rb +8 -2
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +8 -0
  33. data/app/controllers/dbwatcher/sessions_controller.rb +25 -10
  34. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  35. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  36. data/app/helpers/dbwatcher/session_helper.rb +3 -2
  37. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  38. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  39. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  40. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  41. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  42. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  43. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  44. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  45. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  46. data/app/views/dbwatcher/sessions/index.html.erb +14 -10
  47. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  48. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  49. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  50. data/app/views/dbwatcher/sessions/show.html.erb +3 -346
  51. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  52. data/app/views/layouts/dbwatcher/application.html.erb +125 -247
  53. data/bin/compile_scss +49 -0
  54. data/config/routes.rb +26 -0
  55. data/lib/dbwatcher/configuration.rb +102 -8
  56. data/lib/dbwatcher/engine.rb +17 -7
  57. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  58. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  59. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  60. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  61. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  62. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  63. data/lib/dbwatcher/services/base_service.rb +64 -0
  64. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  65. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  66. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  67. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
  68. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  69. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
  70. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  71. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  72. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  73. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  74. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  75. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  76. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  77. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  78. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  79. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  80. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  81. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  82. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  83. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  84. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
  85. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
  86. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  87. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  88. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
  89. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  90. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +15 -128
  91. data/lib/dbwatcher/storage/api/session_api.rb +47 -0
  92. data/lib/dbwatcher/storage/base_storage.rb +7 -0
  93. data/lib/dbwatcher/version.rb +1 -1
  94. data/lib/dbwatcher.rb +58 -1
  95. metadata +94 -2
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "relationship_params"
4
+
5
+ module Dbwatcher
6
+ module Services
7
+ module DiagramData
8
+ # Standard relationship between entities
9
+ #
10
+ # This class provides a standardized representation for all diagram relationships
11
+ # (edges, connections, associations, foreign keys, etc.) with consistent validation.
12
+ #
13
+ # @example
14
+ # relationship = Relationship.new(
15
+ # source_id: "users",
16
+ # target_id: "orders",
17
+ # type: "has_many",
18
+ # label: "orders",
19
+ # cardinality: "one_to_many",
20
+ # metadata: { association_type: "has_many" }
21
+ # )
22
+ # relationship.valid? # => true
23
+ # relationship.to_h # => { source_id: "users", target_id: "orders", ... }
24
+ class Relationship
25
+ attr_accessor :source_id, :target_id, :type, :label, :cardinality, :metadata
26
+
27
+ # Valid cardinality types
28
+ VALID_CARDINALITIES = [
29
+ "one_to_one",
30
+ "one_to_many",
31
+ "many_to_one",
32
+ "many_to_many",
33
+ nil
34
+ ].freeze
35
+
36
+ # Cardinality mapping for relationship types
37
+ CARDINALITY_MAPPING = {
38
+ "has_many" => "one_to_many",
39
+ "belongs_to" => "many_to_one",
40
+ "has_one" => "one_to_one",
41
+ "has_and_belongs_to_many" => "many_to_many"
42
+ }.freeze
43
+
44
+ # ERD cardinality notations
45
+ ERD_NOTATIONS = {
46
+ "one_to_many" => "||--o{",
47
+ "many_to_one" => "}o--||",
48
+ "one_to_one" => "||--||",
49
+ "many_to_many" => "}|--|{"
50
+ }.freeze
51
+
52
+ # Default ERD notation
53
+ DEFAULT_ERD_NOTATION = "||--o{" # Default to one-to-many
54
+
55
+ # Initialize relationship
56
+ #
57
+ # @param params [RelationshipParams, Hash] relationship parameters
58
+ # @return [Relationship] new relationship instance
59
+ def initialize(params)
60
+ params = RelationshipParams.new(params) if params.is_a?(Hash)
61
+
62
+ @source_id = params.source_id.to_s
63
+ @target_id = params.target_id.to_s
64
+ @type = params.type.to_s
65
+ @label = params.label&.to_s
66
+ @cardinality = params.cardinality&.to_s
67
+ @metadata = params.metadata.is_a?(Hash) ? params.metadata : {}
68
+ end
69
+
70
+ # Check if relationship is valid
71
+ #
72
+ # @return [Boolean] true if relationship has required fields
73
+ def valid?
74
+ validation_errors.empty?
75
+ end
76
+
77
+ # Get validation errors
78
+ #
79
+ # @return [Array<String>] array of validation error messages
80
+ def validation_errors
81
+ errors = []
82
+ errors << "Source ID cannot be blank" if source_id.nil? || source_id.to_s.strip.empty?
83
+ errors << "Target ID cannot be blank" if target_id.nil? || target_id.to_s.strip.empty?
84
+ errors << "Type cannot be blank" if type.nil? || type.to_s.strip.empty?
85
+
86
+ # Allow self-referential relationships when explicitly marked as such
87
+ errors << "Source and target cannot be the same" if !metadata[:self_referential] && (source_id == target_id)
88
+
89
+ errors << "Invalid cardinality: #{cardinality}" if cardinality && !VALID_CARDINALITIES.include?(cardinality)
90
+ errors << "Metadata must be a Hash" unless metadata.is_a?(Hash)
91
+ errors
92
+ end
93
+
94
+ # Infer cardinality from relationship type if not explicitly set
95
+ #
96
+ # @return [String, nil] inferred cardinality or nil if can't be determined
97
+ def infer_cardinality
98
+ return cardinality if cardinality
99
+
100
+ CARDINALITY_MAPPING[type]
101
+ end
102
+
103
+ # Get cardinality for ERD notation
104
+ #
105
+ # @return [String] ERD cardinality notation
106
+ def erd_cardinality_notation
107
+ # Default to one-to-many if not recognized
108
+ ERD_NOTATIONS[infer_cardinality] || DEFAULT_ERD_NOTATION
109
+ end
110
+
111
+ # Serialize relationship to hash
112
+ #
113
+ # @return [Hash] serialized relationship data
114
+ def to_h
115
+ {
116
+ source_id: source_id,
117
+ target_id: target_id,
118
+ type: type,
119
+ label: label,
120
+ cardinality: cardinality,
121
+ metadata: metadata
122
+ }
123
+ end
124
+
125
+ # Serialize relationship to JSON
126
+ #
127
+ # @return [String] JSON representation
128
+ def to_json(*args)
129
+ to_h.to_json(*args)
130
+ end
131
+
132
+ # Create relationship from hash
133
+ #
134
+ # @param hash [Hash] relationship data
135
+ # @return [Relationship] new relationship instance
136
+ def self.from_h(hash)
137
+ hash = hash.transform_keys(&:to_sym) if hash.keys.first.is_a?(String)
138
+ new(hash)
139
+ end
140
+
141
+ # Create relationship from JSON
142
+ #
143
+ # @param json [String] JSON string
144
+ # @return [Relationship] new relationship instance
145
+ def self.from_json(json)
146
+ from_h(JSON.parse(json))
147
+ end
148
+
149
+ # Check equality with another relationship
150
+ #
151
+ # @param other [Relationship] other relationship to compare
152
+ # @return [Boolean] true if relationships are equal
153
+ def ==(other)
154
+ return false unless other.is_a?(Relationship)
155
+
156
+ source_id == other.source_id &&
157
+ target_id == other.target_id &&
158
+ type == other.type &&
159
+ label == other.label &&
160
+ cardinality == other.cardinality &&
161
+ metadata == other.metadata
162
+ end
163
+
164
+ # Generate hash code for relationship
165
+ #
166
+ # @return [Integer] hash code
167
+ def hash
168
+ [source_id, target_id, type, label, cardinality, metadata].hash
169
+ end
170
+
171
+ # String representation of relationship
172
+ #
173
+ # @return [String] string representation
174
+ def to_s
175
+ "#{self.class.name}(source: #{source_id}, target: #{target_id}, type: #{type})"
176
+ end
177
+
178
+ # Detailed string representation
179
+ #
180
+ # @return [String] detailed string representation
181
+ def inspect
182
+ "#{self.class.name}(source: #{source_id.inspect}, target: #{target_id.inspect}, " \
183
+ "type: #{type.inspect}, label: #{label.inspect}, cardinality: #{cardinality.inspect})"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramData
6
+ # Parameter object for relationship creation
7
+ #
8
+ # This class encapsulates parameters used for creating relationships
9
+ # to avoid long parameter lists.
10
+ class RelationshipParams
11
+ attr_accessor :source_id, :target_id, :type, :label, :cardinality, :metadata
12
+
13
+ # Initialize relationship parameters
14
+ #
15
+ # @param params [Hash] hash containing all parameters
16
+ # @option params [String] :source_id ID of the source entity
17
+ # @option params [String] :target_id ID of the target entity
18
+ # @option params [String] :type relationship type
19
+ # @option params [String] :label optional display label
20
+ # @option params [String] :cardinality optional cardinality type
21
+ # @option params [Hash] :metadata additional information
22
+ def initialize(params = {})
23
+ @source_id = params[:source_id]
24
+ @target_id = params[:target_id]
25
+ @type = params[:type]
26
+ @label = params[:label]
27
+ @cardinality = params[:cardinality]
28
+ @metadata = params[:metadata] || {}
29
+ end
30
+
31
+ # Create from individual parameters
32
+ #
33
+ # @param params [Hash] parameters hash
34
+ # @return [RelationshipParams] new instance
35
+ def self.create(params)
36
+ new(params)
37
+ end
38
+
39
+ # Convert to hash
40
+ #
41
+ # @return [Hash] hash representation of parameters
42
+ def to_h
43
+ {
44
+ source_id: source_id,
45
+ target_id: target_id,
46
+ type: type,
47
+ label: label,
48
+ cardinality: cardinality,
49
+ metadata: metadata
50
+ }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagram_data/entity"
4
+ require_relative "diagram_data/relationship"
5
+ require_relative "diagram_data/dataset"
6
+
7
+ module Dbwatcher
8
+ module Services
9
+ # DiagramData module provides standardized data models for diagram generation
10
+ #
11
+ # This module contains the core data structures used to represent diagram
12
+ # entities and relationships in a consistent, validated format that can be
13
+ # consumed by any diagram strategy.
14
+ #
15
+ # @example
16
+ # # Create entities
17
+ # user_entity = Dbwatcher::Services::DiagramData::Entity.new(
18
+ # id: "users", name: "User", type: "table"
19
+ # )
20
+ # order_entity = Dbwatcher::Services::DiagramData::Entity.new(
21
+ # id: "orders", name: "Order", type: "table"
22
+ # )
23
+ #
24
+ # # Create relationship
25
+ # relationship = Dbwatcher::Services::DiagramData::Relationship.new(
26
+ # source_id: "users", target_id: "orders", type: "has_many"
27
+ # )
28
+ #
29
+ # # Create dataset
30
+ # dataset = Dbwatcher::Services::DiagramData::Dataset.new
31
+ # dataset.add_entity(user_entity)
32
+ # dataset.add_entity(order_entity)
33
+ # dataset.add_relationship(relationship)
34
+ #
35
+ # # Validate and use
36
+ # if dataset.valid?
37
+ # puts dataset.stats
38
+ # end
39
+ module DiagramData
40
+ # Convenience method to create a new Entity
41
+ #
42
+ # @param args [Hash] entity arguments
43
+ # @return [Entity] new entity instance
44
+ def self.entity(**args)
45
+ Entity.new(**args)
46
+ end
47
+
48
+ # Convenience method to create a new Relationship
49
+ #
50
+ # @param args [Hash] relationship arguments
51
+ # @return [Relationship] new relationship instance
52
+ def self.relationship(**args)
53
+ Relationship.new(**args)
54
+ end
55
+
56
+ # Convenience method to create a new Dataset
57
+ #
58
+ # @param args [Hash] dataset arguments
59
+ # @return [Dataset] new dataset instance
60
+ def self.dataset(**args)
61
+ Dataset.new(**args)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ # Centralized error handling for diagram generation
6
+ #
7
+ # Provides consistent error categorization, logging, and response formatting
8
+ # for all diagram generation failures with recovery strategies.
9
+ #
10
+ # @example
11
+ # handler = DiagramErrorHandler.new
12
+ # response = handler.handle_generation_error(error, context)
13
+ # # => { success: false, error_code: 'DIAGRAM_001', message: '...', recoverable: false }
14
+ class DiagramErrorHandler
15
+ # Custom error class for diagram generation failures
16
+ class DiagramGenerationError < StandardError
17
+ attr_reader :error_code, :context, :original_error
18
+
19
+ def initialize(message, error_code: nil, context: {}, original_error: nil)
20
+ super(message)
21
+ @error_code = error_code
22
+ @context = context
23
+ @original_error = original_error
24
+ end
25
+ end
26
+
27
+ # Error code mapping for consistent error identification
28
+ ERROR_CODES = {
29
+ session_not_found: "DIAGRAM_001",
30
+ invalid_diagram_type: "DIAGRAM_002",
31
+ syntax_validation_failed: "DIAGRAM_003",
32
+ generation_timeout: "DIAGRAM_004",
33
+ insufficient_data: "DIAGRAM_005",
34
+ analyzer_error: "DIAGRAM_006",
35
+ cache_error: "DIAGRAM_007",
36
+ system_error: "DIAGRAM_099"
37
+ }.freeze
38
+
39
+ # Initialize error handler with configuration
40
+ #
41
+ # @param config [Hash] error handler configuration
42
+ # @option config [Logger] :logger logger instance
43
+ # @option config [Boolean] :include_backtrace include backtrace in logs
44
+ # @option config [Integer] :backtrace_lines number of backtrace lines to log
45
+ def initialize(config = {})
46
+ @config = default_config.merge(config)
47
+ @logger = @config[:logger] || default_logger
48
+ end
49
+
50
+ # Handle diagram generation error with categorization and logging
51
+ #
52
+ # @param error [Exception] the original error
53
+ # @param context [Hash] additional context information
54
+ # @return [Hash] formatted error response
55
+ def handle_generation_error(error, context = {})
56
+ error_info = categorize_error(error, context)
57
+ log_error(error_info)
58
+
59
+ # Return user-friendly error response
60
+ create_error_response(error_info)
61
+ end
62
+
63
+ # Check if an error type is recoverable
64
+ #
65
+ # @param error_code [String] error code
66
+ # @return [Boolean] true if error is recoverable
67
+ def recoverable_error?(error_code)
68
+ recoverable_codes = [
69
+ ERROR_CODES[:syntax_validation_failed],
70
+ ERROR_CODES[:generation_timeout],
71
+ ERROR_CODES[:insufficient_data],
72
+ ERROR_CODES[:cache_error]
73
+ ]
74
+ recoverable_codes.include?(error_code)
75
+ end
76
+
77
+ private
78
+
79
+ # Default configuration
80
+ #
81
+ # @return [Hash] default configuration
82
+ def default_config
83
+ {
84
+ include_backtrace: true,
85
+ backtrace_lines: 5
86
+ }
87
+ end
88
+
89
+ # Default logger when no logger is provided
90
+ #
91
+ # @return [Logger] default logger instance
92
+ def default_logger
93
+ # Use Rails logger if available, otherwise create a simple logger
94
+ if defined?(Rails) && Rails.respond_to?(:logger)
95
+ Rails.logger
96
+ else
97
+ require "logger"
98
+ Logger.new($stdout)
99
+ end
100
+ end
101
+
102
+ # Categorize error based on type and context
103
+ #
104
+ # @param error [Exception] the original error
105
+ # @param context [Hash] error context
106
+ # @return [Hash] categorized error information
107
+ def categorize_error(error, context)
108
+ # Check for session not found errors first
109
+ if error.message.include?("Session") && error.message.include?("not found")
110
+ return {
111
+ type: :session_not_found,
112
+ code: ERROR_CODES[:session_not_found],
113
+ message: "Session not found: #{context[:session_id]}",
114
+ recoverable: false,
115
+ original_error: error,
116
+ user_message: "The requested session could not be found. Please verify the session ID."
117
+ }
118
+ end
119
+
120
+ case error
121
+ when ArgumentError
122
+ if error.message.include?("Unknown diagram type") || error.message.include?("Invalid diagram type")
123
+ {
124
+ type: :invalid_diagram_type,
125
+ code: ERROR_CODES[:invalid_diagram_type],
126
+ message: "Invalid diagram type: #{context[:diagram_type]}",
127
+ recoverable: false,
128
+ original_error: error,
129
+ user_message: "The requested diagram type is not supported."
130
+ }
131
+ else
132
+ categorize_generic_error(error, context)
133
+ end
134
+ when StandardError
135
+ if error.message.include?("syntax") || error.message.include?("Mermaid")
136
+ {
137
+ type: :syntax_validation_failed,
138
+ code: ERROR_CODES[:syntax_validation_failed],
139
+ message: "Diagram syntax validation failed: #{error.message}",
140
+ recoverable: true,
141
+ original_error: error,
142
+ user_message: "There was an issue generating the diagram syntax. Please try again."
143
+ }
144
+ elsif error.message.include?("timeout") || error.is_a?(Timeout::Error)
145
+ {
146
+ type: :generation_timeout,
147
+ code: ERROR_CODES[:generation_timeout],
148
+ message: "Diagram generation timed out",
149
+ recoverable: true,
150
+ original_error: error,
151
+ user_message: "Diagram generation took too long. Please try again with a smaller dataset."
152
+ }
153
+ elsif error.message.include?("No") && error.message.include?("found")
154
+ {
155
+ type: :insufficient_data,
156
+ code: ERROR_CODES[:insufficient_data],
157
+ message: "Insufficient data for diagram generation: #{error.message}",
158
+ recoverable: true,
159
+ original_error: error,
160
+ user_message: "Not enough data available to generate the diagram."
161
+ }
162
+ elsif error.message.include?("cache") || error.message.include?("Cache")
163
+ {
164
+ type: :cache_error,
165
+ code: ERROR_CODES[:cache_error],
166
+ message: "Cache operation failed: #{error.message}",
167
+ recoverable: true,
168
+ original_error: error,
169
+ user_message: "A temporary caching issue occurred. The diagram was still generated."
170
+ }
171
+ else
172
+ categorize_generic_error(error, context)
173
+ end
174
+ else
175
+ categorize_generic_error(error, context)
176
+ end
177
+ end
178
+
179
+ # Categorize generic/unknown errors
180
+ #
181
+ # @param error [Exception] the original error
182
+ # @param context [Hash] error context
183
+ # @return [Hash] categorized error information
184
+ def categorize_generic_error(error, _context)
185
+ {
186
+ type: :system_error,
187
+ code: ERROR_CODES[:system_error],
188
+ message: "Unexpected error during diagram generation: #{error.class.name}",
189
+ recoverable: false,
190
+ original_error: error,
191
+ user_message: "An unexpected error occurred. Please try again or contact support."
192
+ }
193
+ end
194
+
195
+ # Log error with appropriate level and detail
196
+ #
197
+ # @param error_info [Hash] categorized error information
198
+ def log_error(error_info)
199
+ log_data = {
200
+ error_code: error_info[:code],
201
+ error_type: error_info[:type],
202
+ message: error_info[:message],
203
+ recoverable: error_info[:recoverable],
204
+ original_error_class: error_info[:original_error]&.class&.name
205
+ }
206
+
207
+ # Add backtrace if configured and available
208
+ if @config[:include_backtrace] && error_info[:original_error]&.backtrace
209
+ log_data[:backtrace] = error_info[:original_error].backtrace.first(@config[:backtrace_lines])
210
+ end
211
+
212
+ # Log at appropriate level based on recoverability
213
+ if error_info[:recoverable]
214
+ @logger.warn "Recoverable diagram generation error: #{error_info[:message]} (#{error_info[:code]})"
215
+ else
216
+ @logger.error "Non-recoverable diagram generation error: #{error_info[:message]} (#{error_info[:code]})"
217
+ end
218
+ end
219
+
220
+ # Create formatted error response
221
+ #
222
+ # @param error_info [Hash] categorized error information
223
+ # @return [Hash] formatted error response
224
+ def create_error_response(error_info)
225
+ {
226
+ success: false,
227
+ error: error_info[:user_message] || error_info[:message],
228
+ error_code: error_info[:code],
229
+ error_type: error_info[:type],
230
+ message: error_info[:user_message] || error_info[:message],
231
+ recoverable: error_info[:recoverable],
232
+ timestamp: Time.now.iso8601,
233
+ content: nil,
234
+ type: nil
235
+ }
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagram_error_handler"
4
+ require_relative "diagram_type_registry"
5
+
6
+ module Dbwatcher
7
+ module Services
8
+ # Orchestrator for diagram generation using strategy pattern
9
+ #
10
+ # This service coordinates diagram generation by delegating to appropriate
11
+ # analyzer and strategy classes with clean error handling.
12
+ #
13
+ # @example
14
+ # generator = DiagramGenerator.new(session_id, 'database_tables')
15
+ # result = generator.call
16
+ # # => { content: "erDiagram\n USERS ||--o{ ORDERS : user_id", type: 'erDiagram' }
17
+ class DiagramGenerator < BaseService
18
+ attr_reader :session_id, :diagram_type, :registry, :error_handler, :logger
19
+
20
+ # Initialize with session id and diagram type
21
+ #
22
+ # @param session_id [String] session identifier
23
+ # @param diagram_type [String] type of diagram to generate
24
+ # @param dependencies [Hash] optional dependency injection
25
+ # @option dependencies [DiagramTypeRegistry] :registry type registry
26
+ # @option dependencies [DiagramErrorHandler] :error_handler error handler
27
+ # @option dependencies [Logger] :logger logger instance
28
+ def initialize(session_id, diagram_type = "database_tables", dependencies = {})
29
+ @session_id = session_id
30
+ @diagram_type = diagram_type
31
+ @registry = dependencies[:registry] || DiagramTypeRegistry.new
32
+ @error_handler = dependencies[:error_handler] || DiagramErrorHandler.new
33
+ @logger = dependencies[:logger] || default_logger
34
+ super()
35
+ end
36
+
37
+ # Generate diagram for session
38
+ #
39
+ # @return [Hash] diagram data with content and type
40
+ def call
41
+ @logger.info("Generating diagram for session #{@session_id} with type #{@diagram_type}")
42
+ start_time = Time.now
43
+
44
+ begin
45
+ result = generate_diagram
46
+ log_completion(start_time, result)
47
+ result
48
+ rescue StandardError => e
49
+ @error_handler.handle_generation_error(e, error_context)
50
+ end
51
+ end
52
+
53
+ # Get available diagram types with metadata
54
+ #
55
+ # @return [Hash] diagram types with metadata
56
+ def available_types
57
+ @registry.available_types_with_metadata
58
+ end
59
+
60
+ # Get available diagram types (class method for backward compatibility)
61
+ #
62
+ # @return [Hash] diagram types with metadata
63
+ def self.available_types
64
+ DiagramTypeRegistry.new.available_types_with_metadata
65
+ end
66
+
67
+ private
68
+
69
+ # Default logger when no logger is provided
70
+ #
71
+ # @return [Logger] default logger instance
72
+ def default_logger
73
+ # Use Rails logger if available, otherwise create a simple logger
74
+ if defined?(Rails) && Rails.respond_to?(:logger)
75
+ Rails.logger
76
+ else
77
+ require "logger"
78
+ Logger.new($stdout)
79
+ end
80
+ end
81
+
82
+ # Generate diagram using standardized analyzer-to-strategy flow
83
+ #
84
+ # @return [Hash] diagram generation result
85
+ def generate_diagram
86
+ # Validate diagram type
87
+ unless @registry.type_exists?(@diagram_type)
88
+ raise DiagramTypeRegistry::UnknownTypeError, "Invalid diagram type: #{@diagram_type}"
89
+ end
90
+
91
+ # Load session
92
+ session = load_session
93
+ return error_result("Session not found") unless session
94
+
95
+ # Create analyzer and generate dataset
96
+ analyzer = @registry.create_analyzer(@diagram_type, session)
97
+ dataset = analyzer.call
98
+
99
+ @logger.debug("Generated dataset with #{dataset.entities.size} entities and " \
100
+ "#{dataset.relationships.size} relationships")
101
+
102
+ # Create strategy and generate diagram from dataset
103
+ strategy = @registry.create_strategy(@diagram_type)
104
+ strategy.generate_from_dataset(dataset)
105
+ end
106
+
107
+ # Load session for analysis
108
+ #
109
+ # @return [Object] session object
110
+ def load_session
111
+ Dbwatcher::Storage.sessions.find(@session_id)
112
+ rescue StandardError => e
113
+ @logger.warn("Could not load session #{@session_id}: #{e.message}")
114
+ nil
115
+ end
116
+
117
+ # Build error context for error handler
118
+ #
119
+ # @return [Hash] error context
120
+ def error_context
121
+ {
122
+ session_id: @session_id,
123
+ diagram_type: @diagram_type,
124
+ timestamp: Time.now
125
+ }
126
+ end
127
+
128
+ # Create error result
129
+ #
130
+ # @param message [String] error message
131
+ # @return [Hash] error result
132
+ def error_result(message)
133
+ {
134
+ success: false,
135
+ error: message,
136
+ content: nil,
137
+ type: nil,
138
+ generated_at: Time.now.iso8601
139
+ }
140
+ end
141
+
142
+ # Log generation completion
143
+ #
144
+ # @param start_time [Time] operation start time
145
+ # @param result [Hash] generation result
146
+ def log_completion(start_time, result)
147
+ duration = Time.now - start_time
148
+ success = result[:success] || false
149
+ @logger.info("Diagram generation completed for session #{@session_id} type #{@diagram_type} " \
150
+ "in #{(duration * 1000).round(2)}ms - Success: #{success}")
151
+ end
152
+ end
153
+ end
154
+ end