dbwatcher 0.1.5 → 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 (137) 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 +101 -0
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +20 -0
  33. data/app/controllers/dbwatcher/queries_controller.rb +24 -0
  34. data/app/controllers/dbwatcher/sessions_controller.rb +30 -20
  35. data/app/controllers/dbwatcher/tables_controller.rb +38 -0
  36. data/app/helpers/dbwatcher/application_helper.rb +103 -0
  37. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  38. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  39. data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
  40. data/app/helpers/dbwatcher/session_helper.rb +28 -0
  41. data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
  42. data/app/views/dbwatcher/queries/index.html.erb +240 -0
  43. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  44. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  45. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  46. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  47. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  48. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  49. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  50. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  51. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  52. data/app/views/dbwatcher/sessions/index.html.erb +124 -27
  53. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  54. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  55. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  56. data/app/views/dbwatcher/sessions/show.html.erb +3 -149
  57. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  58. data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
  59. data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
  60. data/app/views/dbwatcher/shared/_header.html.erb +7 -0
  61. data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
  62. data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
  63. data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
  64. data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
  65. data/app/views/dbwatcher/tables/changes.html.erb +225 -0
  66. data/app/views/dbwatcher/tables/index.html.erb +123 -0
  67. data/app/views/dbwatcher/tables/show.html.erb +86 -0
  68. data/app/views/layouts/dbwatcher/application.html.erb +252 -25
  69. data/bin/compile_scss +49 -0
  70. data/config/routes.rb +43 -3
  71. data/lib/dbwatcher/configuration.rb +103 -1
  72. data/lib/dbwatcher/engine.rb +28 -13
  73. data/lib/dbwatcher/logging.rb +72 -0
  74. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  75. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  76. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  77. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  78. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  79. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  80. data/lib/dbwatcher/services/base_service.rb +64 -0
  81. data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
  82. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  83. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  84. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  85. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
  86. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  87. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
  88. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  89. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  90. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  91. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  92. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  93. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  94. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  95. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  96. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  97. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  98. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  99. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  100. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  101. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  102. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
  103. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
  104. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  105. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  106. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
  107. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  108. data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
  109. data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
  110. data/lib/dbwatcher/sql_logger.rb +107 -0
  111. data/lib/dbwatcher/storage/api/base_api.rb +134 -0
  112. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +59 -0
  113. data/lib/dbwatcher/storage/api/query_api.rb +95 -0
  114. data/lib/dbwatcher/storage/api/session_api.rb +181 -0
  115. data/lib/dbwatcher/storage/api/table_api.rb +86 -0
  116. data/lib/dbwatcher/storage/base_storage.rb +120 -0
  117. data/lib/dbwatcher/storage/change_processor.rb +65 -0
  118. data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
  119. data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
  120. data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
  121. data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
  122. data/lib/dbwatcher/storage/date_helper.rb +21 -0
  123. data/lib/dbwatcher/storage/errors.rb +86 -0
  124. data/lib/dbwatcher/storage/file_manager.rb +122 -0
  125. data/lib/dbwatcher/storage/null_session.rb +39 -0
  126. data/lib/dbwatcher/storage/query_storage.rb +338 -0
  127. data/lib/dbwatcher/storage/query_validator.rb +24 -0
  128. data/lib/dbwatcher/storage/session.rb +58 -0
  129. data/lib/dbwatcher/storage/session_operations.rb +37 -0
  130. data/lib/dbwatcher/storage/session_query.rb +71 -0
  131. data/lib/dbwatcher/storage/session_storage.rb +322 -0
  132. data/lib/dbwatcher/storage/table_storage.rb +237 -0
  133. data/lib/dbwatcher/storage.rb +112 -85
  134. data/lib/dbwatcher/tracker.rb +4 -55
  135. data/lib/dbwatcher/version.rb +1 -1
  136. data/lib/dbwatcher.rb +70 -3
  137. metadata +140 -2
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "digest"
5
+
6
+ module Dbwatcher
7
+ module Services
8
+ # Builder for generating validated Mermaid diagram syntax
9
+ #
10
+ # Provides methods for building different types of Mermaid diagrams with
11
+ # syntax validation, error checking, and consistent formatting.
12
+ #
13
+ # @example
14
+ # builder = MermaidSyntaxBuilder.new
15
+ # content = builder.build_erd_diagram_from_dataset(dataset)
16
+ # # => "erDiagram\n USERS ||--o{ ORDERS : user_id"
17
+ class MermaidSyntaxBuilder
18
+ # Custom error classes
19
+ class SyntaxValidationError < StandardError; end
20
+ class UnsupportedDiagramTypeError < StandardError; end
21
+
22
+ # Supported Mermaid diagram types
23
+ SUPPORTED_DIAGRAM_TYPES = %w[erDiagram classDiagram flowchart graph].freeze
24
+
25
+ # Maximum content length to prevent memory issues
26
+ MAX_CONTENT_LENGTH = 100_000
27
+
28
+ # Initialize builder
29
+ #
30
+ # @param config [Hash] builder configuration (optional)
31
+ # @option config [Logger] :logger logger instance
32
+ def initialize(config = {})
33
+ @config = config
34
+ @logger = config[:logger] || Rails.logger
35
+ end
36
+
37
+ # Build ERD diagram from dataset
38
+ #
39
+ # @param dataset [DiagramData::Dataset] dataset to render
40
+ # @param options [Hash] generation options
41
+ # @return [String] Mermaid ERD syntax
42
+ def build_erd_diagram_from_dataset(dataset, options = {})
43
+ @logger.debug "Building ERD diagram from dataset with #{dataset.entities.size} entities and " \
44
+ "#{dataset.relationships.size} relationships"
45
+
46
+ builder = MermaidSyntax::ErdBuilder.new(@config.merge(options))
47
+ builder.build_from_dataset(dataset)
48
+ end
49
+
50
+ # Build class diagram from dataset
51
+ #
52
+ # @param dataset [DiagramData::Dataset] dataset to render
53
+ # @param options [Hash] generation options
54
+ # @return [String] Mermaid class diagram syntax
55
+ def build_class_diagram_from_dataset(dataset, options = {})
56
+ @logger.debug "Building class diagram from dataset with #{dataset.entities.size} entities and " \
57
+ "#{dataset.relationships.size} relationships"
58
+
59
+ builder = MermaidSyntax::ClassDiagramBuilder.new(@config.merge(options))
60
+ builder.build_from_dataset(dataset)
61
+ end
62
+
63
+ # Build flowchart diagram from dataset
64
+ #
65
+ # @param dataset [DiagramData::Dataset] dataset to render
66
+ # @param options [Hash] generation options
67
+ # @return [String] Mermaid flowchart syntax
68
+ def build_flowchart_diagram_from_dataset(dataset, options = {})
69
+ @logger.debug "Building flowchart diagram from dataset with #{dataset.entities.size} entities and " \
70
+ "#{dataset.relationships.size} relationships"
71
+
72
+ builder = MermaidSyntax::FlowchartBuilder.new(@config.merge(options))
73
+ builder.build_from_dataset(dataset)
74
+ end
75
+
76
+ # Build empty ERD diagram with message
77
+ #
78
+ # @param message [String] message to display
79
+ # @return [String] Mermaid ERD syntax
80
+ def build_empty_erd(message)
81
+ builder = MermaidSyntax::ErdBuilder.new(@config)
82
+ builder.build_empty(message)
83
+ end
84
+
85
+ # Build empty flowchart diagram with message
86
+ #
87
+ # @param message [String] message to display
88
+ # @return [String] Mermaid flowchart syntax
89
+ def build_empty_flowchart(message)
90
+ builder = MermaidSyntax::FlowchartBuilder.new(@config)
91
+ builder.build_empty(message)
92
+ end
93
+
94
+ # Build empty class diagram with message
95
+ #
96
+ # @param message [String] message to display
97
+ # @return [String] Mermaid class diagram syntax
98
+ def build_empty_class_diagram(message)
99
+ builder = MermaidSyntax::ClassDiagramBuilder.new(@config)
100
+ builder.build_empty(message)
101
+ end
102
+
103
+ # Build empty diagram of specified type
104
+ #
105
+ # @param message [String] message to display
106
+ # @param diagram_type [String] type of diagram
107
+ # @return [String] Mermaid syntax
108
+ # @raise [UnsupportedDiagramTypeError] if type unsupported
109
+ def build_empty_diagram(message, diagram_type)
110
+ case diagram_type
111
+ when "erDiagram", "erd"
112
+ build_empty_erd(message)
113
+ when "classDiagram", "class"
114
+ build_empty_class_diagram(message)
115
+ when "flowchart", "graph"
116
+ build_empty_flowchart(message)
117
+ else
118
+ raise UnsupportedDiagramTypeError, "Unsupported diagram type: #{diagram_type}"
119
+ end
120
+ end
121
+
122
+ # Build ERD diagram with isolated tables
123
+ #
124
+ # @param entities [Array<Entity>] isolated table entities
125
+ # @param options [Hash] generation options
126
+ # @return [String] Mermaid ERD syntax
127
+ def build_erd_diagram_with_tables(entities, options = {})
128
+ @logger.debug "Building ERD diagram with #{entities.size} isolated tables"
129
+
130
+ dataset = Dbwatcher::Services::DiagramData::Dataset.new
131
+ entities.each { |entity| dataset.add_entity(entity) }
132
+
133
+ build_erd_diagram_from_dataset(dataset, options)
134
+ end
135
+
136
+ # Build flowchart diagram with isolated nodes
137
+ #
138
+ # @param entities [Array<Entity>] isolated node entities
139
+ # @param options [Hash] generation options
140
+ # @return [String] Mermaid flowchart syntax
141
+ def build_flowchart_with_nodes(entities, options = {})
142
+ @logger.debug "Building flowchart diagram with #{entities.size} isolated nodes"
143
+
144
+ dataset = Dbwatcher::Services::DiagramData::Dataset.new
145
+ entities.each { |entity| dataset.add_entity(entity) }
146
+
147
+ build_flowchart_diagram_from_dataset(dataset, options)
148
+ end
149
+
150
+ # For backward compatibility with legacy code
151
+ alias build_erd_diagram build_erd_diagram_from_dataset
152
+ alias build_flowchart_diagram build_flowchart_diagram_from_dataset
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ # Service object for filtering and sorting queries based on criteria
6
+ # Implements the strategy pattern for different filter types
7
+ class QueryFilterProcessor
8
+ include Dbwatcher::Logging
9
+
10
+ attr_reader :queries, :filter_params
11
+
12
+ # @param queries [Array<Hash>] the queries to filter
13
+ # @param filter_params [Hash] filtering parameters
14
+ def initialize(queries, filter_params)
15
+ @queries = queries
16
+ @filter_params = filter_params
17
+ end
18
+
19
+ # @param queries [Array<Hash>] queries to filter
20
+ # @param filter_params [Hash] filtering parameters
21
+ # @return [Array<Hash>] filtered and sorted queries
22
+ def self.call(queries, filter_params)
23
+ new(queries, filter_params).call
24
+ end
25
+
26
+ def call
27
+ log_filtering_start
28
+ start_time = Time.current
29
+
30
+ result = apply_all_filters
31
+ log_filtering_completion(start_time, result)
32
+
33
+ result
34
+ end
35
+
36
+ private
37
+
38
+ def log_filtering_start
39
+ log_info "Starting query filtering", {
40
+ initial_count: queries.length,
41
+ filters: active_filters.join(", ")
42
+ }
43
+ end
44
+
45
+ def apply_all_filters
46
+ queries
47
+ .then { |q| apply_operation_filter(q) }
48
+ .then { |q| apply_table_filter(q) }
49
+ .then { |q| apply_duration_filter(q) }
50
+ .then { |q| sort_by_timestamp_descending(q) }
51
+ end
52
+
53
+ def log_filtering_completion(start_time, result)
54
+ duration = Time.current - start_time
55
+ log_info "Completed query filtering in #{duration.round(3)}s", {
56
+ final_count: result.length,
57
+ filtered_out: queries.length - result.length
58
+ }
59
+ end
60
+
61
+ def apply_operation_filter(queries)
62
+ return queries unless filter_params[:operation].present?
63
+
64
+ queries.select { |query| matches_operation?(query) }
65
+ end
66
+
67
+ def matches_operation?(query)
68
+ query[:operation] == filter_params[:operation]
69
+ end
70
+
71
+ def apply_table_filter(queries)
72
+ return queries unless filter_params[:table].present?
73
+
74
+ queries.select { |query| includes_table?(query) }
75
+ end
76
+
77
+ def includes_table?(query)
78
+ query[:tables]&.include?(filter_params[:table])
79
+ end
80
+
81
+ def apply_duration_filter(queries)
82
+ return queries unless filter_params[:min_duration].present?
83
+
84
+ min_duration_threshold = filter_params[:min_duration].to_f
85
+ queries.select { |query| exceeds_duration_threshold?(query, min_duration_threshold) }
86
+ end
87
+
88
+ def exceeds_duration_threshold?(query, threshold)
89
+ duration = query[:duration]
90
+ duration && duration >= threshold
91
+ end
92
+
93
+ def sort_by_timestamp_descending(queries)
94
+ queries.sort_by { |query| timestamp_for_sorting(query) }.reverse
95
+ end
96
+
97
+ def timestamp_for_sorting(query)
98
+ return 0 unless query[:timestamp]
99
+
100
+ Time.parse(query[:timestamp]).to_i
101
+ rescue ArgumentError
102
+ 0
103
+ end
104
+
105
+ def active_filters
106
+ filters = []
107
+ filters << "operation=#{filter_params[:operation]}" if filter_params[:operation].present?
108
+ filters << "table=#{filter_params[:table]}" if filter_params[:table].present?
109
+ filters << "min_duration=#{filter_params[:min_duration]}" if filter_params[:min_duration].present?
110
+ filters.empty? ? ["none"] : filters
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ # Service object for collecting and organizing table statistics
6
+ # Follows the command pattern with self.call class method
7
+ class TableStatisticsCollector
8
+ include Dbwatcher::Logging
9
+
10
+ # @return [Array<Hash>] sorted array of table statistics
11
+ def self.call
12
+ new.call
13
+ end
14
+
15
+ def call
16
+ log_info "Starting table statistics collection"
17
+ start_time = Time.current
18
+
19
+ tables = build_initial_tables_hash
20
+ populate_change_statistics(tables)
21
+ result = sort_by_change_count(tables)
22
+
23
+ duration = Time.current - start_time
24
+ log_info "Completed table statistics collection in #{duration.round(3)}s", {
25
+ tables_count: result.length,
26
+ total_changes: result.sum { |t| t[:change_count] }
27
+ }
28
+
29
+ result
30
+ end
31
+
32
+ private
33
+
34
+ def build_initial_tables_hash
35
+ tables = {}
36
+ schema_tables_count = load_schema_tables(tables)
37
+ log_schema_loading_result(schema_tables_count)
38
+ tables
39
+ end
40
+
41
+ def load_schema_tables(tables)
42
+ return 0 unless schema_available?
43
+
44
+ schema_tables_count = 0
45
+ begin
46
+ ActiveRecord::Base.connection.tables.each do |table|
47
+ tables[table] = build_table_entry(table)
48
+ schema_tables_count += 1
49
+ end
50
+ schema_tables_count
51
+ rescue StandardError => e
52
+ log_warn "Could not load tables from schema: #{e.message}"
53
+ 0
54
+ end
55
+ end
56
+
57
+ def schema_available?
58
+ defined?(ActiveRecord::Base)
59
+ end
60
+
61
+ def log_schema_loading_result(count)
62
+ if count.positive?
63
+ log_debug "Loaded #{count} tables from database schema"
64
+ else
65
+ log_debug "ActiveRecord not available, starting with empty tables hash"
66
+ end
67
+ end
68
+
69
+ def build_table_entry(table_name)
70
+ {
71
+ name: table_name,
72
+ change_count: 0,
73
+ last_change: nil
74
+ }
75
+ end
76
+
77
+ def populate_change_statistics(tables)
78
+ sessions_processed = 0
79
+ total_changes = 0
80
+
81
+ Storage.sessions.all.each do |session_info|
82
+ session = Storage.sessions.find(session_info[:id])
83
+ next unless session
84
+
85
+ session_changes_count = session.changes.length
86
+ update_tables_from_session(tables, session)
87
+ sessions_processed += 1
88
+ total_changes += session_changes_count
89
+ end
90
+
91
+ log_debug "Processed #{sessions_processed} sessions with #{total_changes} total changes"
92
+ tables
93
+ end
94
+
95
+ def update_tables_from_session(tables, session)
96
+ session.changes.each do |change|
97
+ table_name = change[:table_name]
98
+ next if table_name.nil? || table_name.empty?
99
+
100
+ tables[table_name] ||= build_table_entry(table_name)
101
+ update_table_change_statistics(tables[table_name], change)
102
+ end
103
+ end
104
+
105
+ def update_table_change_statistics(table_stats, change)
106
+ table_stats[:change_count] += 1
107
+ timestamp = change[:timestamp]
108
+
109
+ return unless table_stats[:last_change].nil? || timestamp > table_stats[:last_change]
110
+
111
+ table_stats[:last_change] = timestamp
112
+ end
113
+
114
+ def sort_by_change_count(tables)
115
+ tables.values.sort_by { |table| -table[:change_count] }
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ class SqlLogger
5
+ include Singleton
6
+
7
+ attr_reader :queries
8
+
9
+ def initialize
10
+ @queries = []
11
+ @mutex = Mutex.new
12
+ setup_subscriber if Dbwatcher.configuration.track_queries
13
+ end
14
+
15
+ def log_query(sql, name, binds, _type_casted_binds, duration)
16
+ return unless Dbwatcher.configuration.track_queries
17
+
18
+ @mutex.synchronize do
19
+ query = create_query_record(sql, name, binds, duration)
20
+ store_query(query)
21
+ end
22
+ end
23
+
24
+ def create_query_record(sql, name, binds, duration)
25
+ {
26
+ id: SecureRandom.uuid,
27
+ sql: sql,
28
+ name: name,
29
+ binds: binds,
30
+ duration: duration,
31
+ timestamp: Time.current,
32
+ session_id: current_session_id,
33
+ backtrace: filtered_backtrace,
34
+ tables: extract_tables(sql),
35
+ operation: extract_operation(sql)
36
+ }
37
+ end
38
+
39
+ def store_query(query)
40
+ @queries << query
41
+ Storage.queries.create(query)
42
+ end
43
+
44
+ def clear_queries
45
+ @mutex.synchronize do
46
+ @queries.clear
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def setup_subscriber
53
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, start, finish, _id, payload|
54
+ next if skip_query?(payload)
55
+
56
+ duration = (finish - start) * 1000.0
57
+ log_query(
58
+ payload[:sql],
59
+ payload[:name],
60
+ payload[:binds],
61
+ payload[:type_casted_binds],
62
+ duration
63
+ )
64
+ end
65
+ end
66
+
67
+ def skip_query?(payload)
68
+ skip_schema_query?(payload) || skip_internal_query?(payload)
69
+ end
70
+
71
+ def skip_schema_query?(payload)
72
+ payload[:name]&.include?("SCHEMA")
73
+ end
74
+
75
+ def skip_internal_query?(payload)
76
+ return true if payload[:sql]&.include?("sqlite_master")
77
+ return true if payload[:sql]&.include?("PRAGMA")
78
+ return true if payload[:sql]&.include?("information_schema")
79
+
80
+ false
81
+ end
82
+
83
+ def extract_tables(sql)
84
+ # Extract table names from SQL
85
+ tables = []
86
+ # Match FROM, JOIN, INTO, UPDATE, DELETE FROM patterns
87
+ sql.scan(/(?:FROM|JOIN|INTO|UPDATE|DELETE\s+FROM)\s+["`]?(\w+)["`]?/i) do |match|
88
+ tables << match[0]
89
+ end
90
+ tables.uniq
91
+ end
92
+
93
+ def extract_operation(sql)
94
+ sql.strip.split(/\s+/).first.upcase
95
+ end
96
+
97
+ def filtered_backtrace
98
+ caller.select { |line| line.include?(Rails.root.to_s) }
99
+ .reject { |line| line.include?("dbwatcher") }
100
+ .first(5)
101
+ end
102
+
103
+ def current_session_id
104
+ Tracker.current_session&.id
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ module Api
6
+ # Base class for all storage API classes
7
+ #
8
+ # This class provides common functionality and patterns for all
9
+ # storage API implementations (SessionAPI, QueryAPI, TableAPI).
10
+ # It establishes the foundation for the fluent interface pattern
11
+ # and shared filtering capabilities.
12
+ #
13
+ # @abstract Subclass and implement specific API methods
14
+ # @example
15
+ # class MyAPI < BaseAPI
16
+ # def my_filter(value)
17
+ # @filters[:my_key] = value
18
+ # self
19
+ # end
20
+ # end
21
+ class BaseAPI
22
+ include Storage::Concerns::DataNormalizer
23
+
24
+ # Initialize the API with a storage backend
25
+ #
26
+ # @param storage [Object] storage backend instance
27
+ def initialize(storage)
28
+ @storage = storage
29
+ @filters = {}
30
+ @limit_value = nil
31
+ end
32
+
33
+ # Apply limit to results
34
+ #
35
+ # @param count [Integer] maximum number of results
36
+ # @return [BaseAPI] self for method chaining
37
+ def limit(count)
38
+ @limit_value = count
39
+ self
40
+ end
41
+
42
+ # Filter by conditions
43
+ #
44
+ # @param conditions [Hash] filtering conditions
45
+ # @return [BaseAPI] self for method chaining
46
+ def where(conditions)
47
+ @filters.merge!(conditions)
48
+ self
49
+ end
50
+
51
+ # Get all results after applying filters
52
+ #
53
+ # @return [Array] filtered results
54
+ # @abstract Subclasses should implement this method
55
+ def all
56
+ raise NotImplementedError, "Subclasses must implement #all"
57
+ end
58
+
59
+ # Create a new record
60
+ #
61
+ # @param data [Hash] record data
62
+ # @return [Hash] created record
63
+ # @abstract Subclasses should implement this method if creation is supported
64
+ def create(data)
65
+ @storage.save(data)
66
+ end
67
+
68
+ protected
69
+
70
+ attr_reader :storage, :filters, :limit_value
71
+
72
+ # Apply common filters to a result set
73
+ #
74
+ # @param results [Array] raw results
75
+ # @return [Array] filtered results
76
+ def apply_common_filters(results)
77
+ result = results
78
+
79
+ # Apply limit if specified
80
+ result = result.first(limit_value) if limit_value
81
+
82
+ result
83
+ end
84
+
85
+ # Apply time-based filtering
86
+ #
87
+ # @param results [Array] results to filter
88
+ # @param time_field [Symbol] field containing timestamp
89
+ # @return [Array] filtered results
90
+ def apply_time_filter(results, time_field)
91
+ return results unless filters[:started_after]
92
+
93
+ cutoff = filters[:started_after]
94
+ results.select do |item|
95
+ timestamp = item[time_field]
96
+ next false unless timestamp
97
+
98
+ begin
99
+ Time.parse(timestamp.to_s) >= cutoff
100
+ rescue ArgumentError
101
+ false
102
+ end
103
+ end
104
+ end
105
+
106
+ # Apply pattern matching filter
107
+ #
108
+ # @param results [Array] results to filter
109
+ # @param fields [Array<Symbol>] fields to search in
110
+ # @param pattern [String] pattern to match
111
+ # @return [Array] filtered results
112
+ def apply_pattern_filter(results, fields, pattern)
113
+ return results unless pattern
114
+
115
+ results.select do |item|
116
+ fields.any? do |field|
117
+ value = item[field]
118
+ value&.to_s&.include?(pattern)
119
+ end
120
+ end
121
+ end
122
+
123
+ # Safe value extraction with normalization
124
+ #
125
+ # @param item [Hash] item to extract from
126
+ # @param key [Symbol] key to extract
127
+ # @return [Object] extracted value
128
+ def safe_extract(item, key)
129
+ extract_value(item, key.to_s)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ module Api
6
+ module Concerns
7
+ # Provides reusable table analysis functionality for API classes
8
+ #
9
+ # This concern now acts as a facade, delegating specific responsibilities
10
+ # to specialized service classes while maintaining backward compatibility.
11
+ #
12
+ # @example
13
+ # class MyAPI < BaseAPI
14
+ # include Api::Concerns::TableAnalyzer
15
+ #
16
+ # def analyze(session)
17
+ # build_tables_summary(session)
18
+ # end
19
+ # end
20
+ module TableAnalyzer
21
+ # Build tables summary from session changes
22
+ #
23
+ # @param session [Session] session to analyze
24
+ # @return [Hash] tables summary hash
25
+ def build_tables_summary(session)
26
+ # Delegate to new service while maintaining interface compatibility
27
+ Dbwatcher::Services::Analyzers::TableSummaryBuilder.call(session)
28
+ end
29
+
30
+ # Process all changes in a session (legacy method for backward compatibility)
31
+ #
32
+ # @param session [Session] session with changes
33
+ # @param _tables [Hash] tables hash to populate (unused but kept for compatibility)
34
+ # @return [void]
35
+ def process_session_changes(session, _tables)
36
+ # Use new service for processing but maintain yield interface
37
+ processor = Dbwatcher::Services::Analyzers::SessionDataProcessor.new(session)
38
+ processor.process_changes do |table_name, change, _|
39
+ yield(table_name, change) if block_given?
40
+ end
41
+ end
42
+
43
+ # Legacy methods maintained for backward compatibility
44
+ # These now delegate to the new service classes
45
+
46
+ # Extract table name from change data (legacy compatibility)
47
+ #
48
+ # @param change [Hash] change data
49
+ # @return [String, nil] table name or nil
50
+ def extract_table_name(change)
51
+ return nil unless change.is_a?(Hash)
52
+
53
+ change[:table_name]
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end