whodunit-chronicles 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -226
- data/LICENSE +1 -1
- data/README.md +96 -599
- data/exe/whodunit-chronicles +6 -0
- data/lib/whodunit/chronicles/chronicler.rb +62 -0
- data/lib/whodunit/chronicles/cli.rb +131 -0
- data/lib/whodunit/chronicles/errors.rb +7 -33
- data/lib/whodunit/chronicles/ledger.rb +69 -0
- data/lib/whodunit/chronicles/ledger_entry.rb +143 -0
- data/lib/whodunit/chronicles/ledger_factory.rb +66 -0
- data/lib/whodunit/chronicles/ledgers/file_ledger.rb +56 -0
- data/lib/whodunit/chronicles/ledgers/memory_ledger.rb +29 -0
- data/lib/whodunit/chronicles/ledgers/sqlite_ledger.rb +172 -0
- data/lib/whodunit/chronicles/version.rb +2 -1
- data/lib/whodunit/chronicles.rb +12 -65
- data/lib/whodunit-chronicles.rb +0 -1
- data/sig/whodunit/chronicles/chronicler.rbs +14 -0
- data/sig/whodunit/chronicles/cli.rbs +17 -0
- data/sig/whodunit/chronicles/errors.rbs +15 -0
- data/sig/whodunit/chronicles/ledger.rbs +13 -0
- data/sig/whodunit/chronicles/ledger_entry.rbs +62 -0
- data/sig/whodunit/chronicles/ledger_factory.rbs +14 -0
- data/sig/whodunit/chronicles/ledgers/file_ledger.rbs +14 -0
- data/sig/whodunit/chronicles/ledgers/memory_ledger.rbs +12 -0
- data/sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs +30 -0
- data/sig/whodunit/chronicles.rbs +5 -0
- metadata +40 -326
- data/.codeclimate.yml +0 -50
- data/.rubocop.yml +0 -93
- data/.yardopts +0 -14
- data/CODE_OF_CONDUCT.md +0 -132
- data/CONTRIBUTING.md +0 -186
- data/Rakefile +0 -18
- data/docker/mysql/init.sql +0 -33
- data/docker/postgres/init.sql +0 -40
- data/docker-compose.yml +0 -138
- data/examples/images/campaign-performance-analytics.png +0 -0
- data/examples/images/candidate-journey-analytics.png +0 -0
- data/examples/images/recruitment-funnel-analytics.png +0 -0
- data/lib/.gitkeep +0 -0
- data/lib/whodunit/chronicles/adapter_loader.rb +0 -69
- data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
- data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
- data/lib/whodunit/chronicles/change_event.rb +0 -201
- data/lib/whodunit/chronicles/composite_processor.rb +0 -86
- data/lib/whodunit/chronicles/configuration.rb +0 -112
- data/lib/whodunit/chronicles/connection.rb +0 -88
- data/lib/whodunit/chronicles/persistence.rb +0 -129
- data/lib/whodunit/chronicles/processor.rb +0 -127
- data/lib/whodunit/chronicles/service.rb +0 -207
- data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
- data/lib/whodunit/chronicles/table.rb +0 -120
- data/whodunit-chronicles.gemspec +0 -79
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Whodunit
|
|
4
|
-
module Chronicles
|
|
5
|
-
# Represents a database change event in a common format
|
|
6
|
-
#
|
|
7
|
-
# This class normalizes database changes from different sources
|
|
8
|
-
# (PostgreSQL WAL, MariaDB binlog, etc.) into a consistent format
|
|
9
|
-
# for processing by audit systems.
|
|
10
|
-
class ChangeEvent
|
|
11
|
-
# Supported database actions
|
|
12
|
-
ACTIONS = %w[INSERT UPDATE DELETE].freeze
|
|
13
|
-
|
|
14
|
-
attr_reader :table_name, :schema_name, :action, :primary_key, :old_data, :new_data,
|
|
15
|
-
:timestamp, :transaction_id, :sequence_number, :metadata
|
|
16
|
-
|
|
17
|
-
# Initialize a new change event
|
|
18
|
-
#
|
|
19
|
-
# @param table_name [String] The name of the table that changed
|
|
20
|
-
# @param action [String] The type of change (INSERT, UPDATE, DELETE)
|
|
21
|
-
# @param primary_key [Hash] The primary key values for the changed row
|
|
22
|
-
# @param old_data [Hash, nil] The row data before the change (nil for INSERT)
|
|
23
|
-
# @param new_data [Hash, nil] The row data after the change (nil for DELETE)
|
|
24
|
-
# @param timestamp [Time] When the change occurred
|
|
25
|
-
# @param schema_name [String] The schema name (optional, defaults to 'public')
|
|
26
|
-
# @param transaction_id [String, Integer] Database transaction identifier
|
|
27
|
-
# @param sequence_number [Integer] Sequence number within the transaction
|
|
28
|
-
# @param metadata [Hash] Additional adapter-specific metadata
|
|
29
|
-
def initialize(
|
|
30
|
-
table_name:,
|
|
31
|
-
action:,
|
|
32
|
-
primary_key:,
|
|
33
|
-
old_data: nil,
|
|
34
|
-
new_data: nil,
|
|
35
|
-
timestamp: Time.now,
|
|
36
|
-
schema_name: 'public',
|
|
37
|
-
transaction_id: nil,
|
|
38
|
-
sequence_number: nil,
|
|
39
|
-
metadata: {}
|
|
40
|
-
)
|
|
41
|
-
@table_name = table_name.to_s
|
|
42
|
-
@schema_name = schema_name.to_s
|
|
43
|
-
@action = validate_action(action.to_s.upcase)
|
|
44
|
-
@primary_key = primary_key || {}
|
|
45
|
-
@old_data = old_data
|
|
46
|
-
@new_data = new_data
|
|
47
|
-
@timestamp = timestamp
|
|
48
|
-
@transaction_id = transaction_id
|
|
49
|
-
@sequence_number = sequence_number
|
|
50
|
-
@metadata = metadata || {}
|
|
51
|
-
|
|
52
|
-
validate_data_consistency
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Get the qualified table name (schema.table)
|
|
56
|
-
#
|
|
57
|
-
# @return [String]
|
|
58
|
-
def qualified_table_name
|
|
59
|
-
"#{schema_name}.#{table_name}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Check if this is a create event
|
|
63
|
-
#
|
|
64
|
-
# @return [Boolean]
|
|
65
|
-
def create?
|
|
66
|
-
action == 'INSERT'
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Check if this is an update event
|
|
70
|
-
#
|
|
71
|
-
# @return [Boolean]
|
|
72
|
-
def update?
|
|
73
|
-
action == 'UPDATE'
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Check if this is a delete event
|
|
77
|
-
#
|
|
78
|
-
# @return [Boolean]
|
|
79
|
-
def delete?
|
|
80
|
-
action == 'DELETE'
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Get the changed columns for UPDATE events
|
|
84
|
-
#
|
|
85
|
-
# @return [Array<String>] Array of column names that changed
|
|
86
|
-
def changed_columns
|
|
87
|
-
return [] unless update? && old_data && new_data
|
|
88
|
-
|
|
89
|
-
old_data.keys.reject { |key| old_data[key] == new_data[key] }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Get a hash of changes in [old_value, new_value] format
|
|
93
|
-
#
|
|
94
|
-
# @return [Hash] Hash of column_name => [old_value, new_value]
|
|
95
|
-
def changes
|
|
96
|
-
return {} unless update? && old_data && new_data
|
|
97
|
-
|
|
98
|
-
changed_columns.to_h do |column|
|
|
99
|
-
[column, [old_data[column], new_data[column]]]
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# Get the current data for this event
|
|
104
|
-
#
|
|
105
|
-
# @return [Hash] The new_data for INSERT/UPDATE, old_data for DELETE
|
|
106
|
-
def current_data
|
|
107
|
-
case action
|
|
108
|
-
when 'INSERT', 'UPDATE'
|
|
109
|
-
new_data
|
|
110
|
-
when 'DELETE'
|
|
111
|
-
old_data
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# Get all available data for this event
|
|
116
|
-
#
|
|
117
|
-
# @return [Hash] Combined old and new data
|
|
118
|
-
def all_data
|
|
119
|
-
(old_data || {}).merge(new_data || {})
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Convert to hash representation
|
|
123
|
-
#
|
|
124
|
-
# @return [Hash]
|
|
125
|
-
def to_h
|
|
126
|
-
{
|
|
127
|
-
table_name: table_name,
|
|
128
|
-
schema_name: schema_name,
|
|
129
|
-
qualified_table_name: qualified_table_name,
|
|
130
|
-
action: action,
|
|
131
|
-
primary_key: primary_key,
|
|
132
|
-
old_data: old_data,
|
|
133
|
-
new_data: new_data,
|
|
134
|
-
current_data: current_data,
|
|
135
|
-
changes: changes,
|
|
136
|
-
changed_columns: changed_columns,
|
|
137
|
-
timestamp: timestamp,
|
|
138
|
-
transaction_id: transaction_id,
|
|
139
|
-
sequence_number: sequence_number,
|
|
140
|
-
metadata: metadata,
|
|
141
|
-
}
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# String representation
|
|
145
|
-
#
|
|
146
|
-
# @return [String]
|
|
147
|
-
def to_s
|
|
148
|
-
pk_str = primary_key.map { |k, v| "#{k}=#{v}" }.join(', ')
|
|
149
|
-
"#{action} #{qualified_table_name}(#{pk_str}) at #{timestamp}"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Detailed string representation
|
|
153
|
-
#
|
|
154
|
-
# @return [String]
|
|
155
|
-
def inspect
|
|
156
|
-
"#<#{self.class.name} #{self}>"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# Compare events for equality
|
|
160
|
-
#
|
|
161
|
-
# @param other [ChangeEvent]
|
|
162
|
-
# @return [Boolean]
|
|
163
|
-
def ==(other)
|
|
164
|
-
return false unless other.is_a?(ChangeEvent)
|
|
165
|
-
|
|
166
|
-
table_name == other.table_name &&
|
|
167
|
-
schema_name == other.schema_name &&
|
|
168
|
-
action == other.action &&
|
|
169
|
-
primary_key == other.primary_key &&
|
|
170
|
-
old_data == other.old_data &&
|
|
171
|
-
new_data == other.new_data &&
|
|
172
|
-
timestamp == other.timestamp &&
|
|
173
|
-
transaction_id == other.transaction_id &&
|
|
174
|
-
sequence_number == other.sequence_number
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
private
|
|
178
|
-
|
|
179
|
-
def validate_action(action)
|
|
180
|
-
unless ACTIONS.include?(action)
|
|
181
|
-
raise ArgumentError, "Invalid action: #{action}. Must be one of: #{ACTIONS.join(', ')}"
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
action
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def validate_data_consistency
|
|
188
|
-
case action
|
|
189
|
-
when 'INSERT'
|
|
190
|
-
raise ArgumentError, 'INSERT events must have new_data' if new_data.nil? || new_data.empty?
|
|
191
|
-
raise ArgumentError, 'INSERT events should not have old_data' unless old_data.nil?
|
|
192
|
-
when 'UPDATE'
|
|
193
|
-
raise ArgumentError, 'UPDATE events must have both old_data and new_data' if old_data.nil? || new_data.nil?
|
|
194
|
-
when 'DELETE'
|
|
195
|
-
raise ArgumentError, 'DELETE events must have old_data' if old_data.nil? || old_data.empty?
|
|
196
|
-
raise ArgumentError, 'DELETE events should not have new_data' unless new_data.nil?
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
end
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Whodunit
|
|
4
|
-
module Chronicles
|
|
5
|
-
# Fans out a single change event to multiple processors in sequence.
|
|
6
|
-
#
|
|
7
|
-
# Use this to build pipelines — e.g. simultaneously storing audit records,
|
|
8
|
-
# streaming to Grafana, and triggering alerts — without coupling any single
|
|
9
|
-
# processor to the others.
|
|
10
|
-
#
|
|
11
|
-
# Each processor runs independently. If one raises, the error is logged and
|
|
12
|
-
# the remaining processors still execute (fail-open by default). Set
|
|
13
|
-
# `fail_fast: true` to instead halt the chain on the first error.
|
|
14
|
-
#
|
|
15
|
-
# @example Basic pipeline
|
|
16
|
-
# service = Whodunit::Chronicles::Service.new(
|
|
17
|
-
# adapter: adapter,
|
|
18
|
-
# processor: Whodunit::Chronicles::CompositeProcessor.new([
|
|
19
|
-
# AuditStoreProcessor.new,
|
|
20
|
-
# AlertingProcessor.new,
|
|
21
|
-
# GrafanaProcessor.new
|
|
22
|
-
# ])
|
|
23
|
-
# )
|
|
24
|
-
#
|
|
25
|
-
# @example Halt on first error
|
|
26
|
-
# CompositeProcessor.new(processors, fail_fast: true)
|
|
27
|
-
#
|
|
28
|
-
class CompositeProcessor
|
|
29
|
-
# @param processors [Array<#process>] ordered list of processors to invoke
|
|
30
|
-
# @param fail_fast [Boolean] when true, halt the chain on the first error
|
|
31
|
-
# @param logger [Logger, nil] optional logger; defaults to Chronicles logger
|
|
32
|
-
def initialize(processors, fail_fast: false, logger: nil)
|
|
33
|
-
raise ArgumentError, 'processors must be an Array' unless processors.is_a?(Array)
|
|
34
|
-
raise ArgumentError, 'processors cannot be empty' if processors.empty?
|
|
35
|
-
|
|
36
|
-
@processors = processors
|
|
37
|
-
@fail_fast = fail_fast
|
|
38
|
-
@logger = logger || Whodunit::Chronicles.logger
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Process a change event through every processor in the chain.
|
|
42
|
-
#
|
|
43
|
-
# @param change_event [ChangeEvent] the event to process
|
|
44
|
-
# @return [void]
|
|
45
|
-
# @raise [ProcessingError] only when fail_fast is true and a child raises
|
|
46
|
-
def process(change_event)
|
|
47
|
-
errors = []
|
|
48
|
-
|
|
49
|
-
@processors.each do |processor|
|
|
50
|
-
processor.process(change_event)
|
|
51
|
-
rescue StandardError => e
|
|
52
|
-
raise ProcessingError, "#{processor.class} failed: #{e.message}" if @fail_fast
|
|
53
|
-
|
|
54
|
-
@logger.error { "CompositeProcessor: #{processor.class} raised #{e.class}: #{e.message}" }
|
|
55
|
-
errors << e
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
return if errors.empty?
|
|
59
|
-
|
|
60
|
-
@logger.warn do
|
|
61
|
-
"CompositeProcessor: #{errors.size} processor(s) failed for #{change_event.table_name}##{change_event.action}"
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# @return [Integer] the number of processors in the chain
|
|
66
|
-
def size
|
|
67
|
-
@processors.size
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# @return [Array<Class>] the processor classes in chain order
|
|
71
|
-
def processor_classes
|
|
72
|
-
@processors.map(&:class)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Append a processor to the end of the chain.
|
|
76
|
-
#
|
|
77
|
-
# @param processor [#process] the processor to add
|
|
78
|
-
# @return [self]
|
|
79
|
-
def add(processor)
|
|
80
|
-
@processors << processor
|
|
81
|
-
self
|
|
82
|
-
end
|
|
83
|
-
alias << add
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Whodunit
|
|
4
|
-
module Chronicles
|
|
5
|
-
# Configuration management for Chronicles
|
|
6
|
-
#
|
|
7
|
-
# Provides a centralized configuration system with sensible defaults
|
|
8
|
-
# and validation for all Chronicles settings.
|
|
9
|
-
class Configuration
|
|
10
|
-
attr_accessor :database_url, :audit_database_url, :adapter, :publication_name,
|
|
11
|
-
:replication_slot_name, :batch_size, :max_retry_attempts, :retry_delay,
|
|
12
|
-
:logger, :table_filter, :schema_filter
|
|
13
|
-
|
|
14
|
-
def initialize
|
|
15
|
-
@database_url = ENV.fetch('DATABASE_URL', nil)
|
|
16
|
-
@audit_database_url = ENV.fetch('AUDIT_DATABASE_URL', nil)
|
|
17
|
-
@adapter = :postgresql
|
|
18
|
-
@publication_name = 'whodunit_audit'
|
|
19
|
-
@replication_slot_name = 'whodunit_audit_slot'
|
|
20
|
-
@batch_size = 100
|
|
21
|
-
@max_retry_attempts = 3
|
|
22
|
-
@retry_delay = 5
|
|
23
|
-
@logger = Dry::Logger.new
|
|
24
|
-
@table_filter = nil
|
|
25
|
-
@schema_filter = nil
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Validate configuration settings
|
|
29
|
-
#
|
|
30
|
-
# @raise [ConfigurationError] if configuration is invalid
|
|
31
|
-
def validate!
|
|
32
|
-
raise ConfigurationError, 'database_url is required' if database_url.nil?
|
|
33
|
-
raise ConfigurationError, 'adapter must be :postgresql or :mysql' unless %i[postgresql mysql].include?(adapter)
|
|
34
|
-
raise ConfigurationError, 'batch_size must be positive' unless batch_size.positive?
|
|
35
|
-
raise ConfigurationError, 'max_retry_attempts must be positive' unless max_retry_attempts.positive?
|
|
36
|
-
raise ConfigurationError, 'retry_delay must be positive' unless retry_delay.positive?
|
|
37
|
-
|
|
38
|
-
validate_adapter_specific_settings!
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Check if a table should be chronicled based on filters
|
|
42
|
-
#
|
|
43
|
-
# @param table_name [String] The table name to check
|
|
44
|
-
# @param schema_name [String] The schema name to check
|
|
45
|
-
# @return [Boolean] true if the table should be chronicled
|
|
46
|
-
def chronicle_table?(table_name, schema_name = 'public')
|
|
47
|
-
return false if filtered_by_schema?(schema_name)
|
|
48
|
-
return false if filtered_by_table?(table_name)
|
|
49
|
-
|
|
50
|
-
true
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
private
|
|
54
|
-
|
|
55
|
-
def validate_adapter_specific_settings!
|
|
56
|
-
case adapter
|
|
57
|
-
when :postgresql
|
|
58
|
-
validate_postgresql_settings!
|
|
59
|
-
when :mysql
|
|
60
|
-
validate_mysql_settings!
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def validate_postgresql_settings!
|
|
65
|
-
if publication_name && !/\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(publication_name)
|
|
66
|
-
raise ConfigurationError, 'publication_name must be a valid PostgreSQL identifier'
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
return unless replication_slot_name && !/\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(replication_slot_name)
|
|
70
|
-
|
|
71
|
-
raise ConfigurationError, 'replication_slot_name must be a valid PostgreSQL identifier'
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def validate_mysql_settings!
|
|
75
|
-
# MySQL-specific validations can be added here in the future
|
|
76
|
-
# For now, MySQL settings are less restrictive
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def filtered_by_schema?(schema_name)
|
|
80
|
-
return false unless schema_filter
|
|
81
|
-
|
|
82
|
-
case schema_filter
|
|
83
|
-
when Array
|
|
84
|
-
!schema_filter.include?(schema_name)
|
|
85
|
-
when String, Symbol
|
|
86
|
-
schema_name != schema_filter.to_s
|
|
87
|
-
when Proc
|
|
88
|
-
!schema_filter.call(schema_name)
|
|
89
|
-
else
|
|
90
|
-
false
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def filtered_by_table?(table_name)
|
|
95
|
-
return false unless table_filter
|
|
96
|
-
|
|
97
|
-
case table_filter
|
|
98
|
-
when Array
|
|
99
|
-
!table_filter.include?(table_name)
|
|
100
|
-
when String, Symbol
|
|
101
|
-
table_name != table_filter.to_s
|
|
102
|
-
when Regexp
|
|
103
|
-
!table_filter.match?(table_name)
|
|
104
|
-
when Proc
|
|
105
|
-
!table_filter.call(table_name)
|
|
106
|
-
else
|
|
107
|
-
false
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'uri'
|
|
4
|
-
|
|
5
|
-
module Whodunit
|
|
6
|
-
module Chronicles
|
|
7
|
-
# Handles database connections for chronicles processing
|
|
8
|
-
#
|
|
9
|
-
# Provides adapter-agnostic connection management for both PostgreSQL and MySQL
|
|
10
|
-
module Connection
|
|
11
|
-
private
|
|
12
|
-
|
|
13
|
-
def create_connection
|
|
14
|
-
audit_url = @audit_database_url || Chronicles.config.database_url
|
|
15
|
-
|
|
16
|
-
case detect_database_type(audit_url)
|
|
17
|
-
when :postgresql
|
|
18
|
-
require 'pg'
|
|
19
|
-
PG.connect(audit_url)
|
|
20
|
-
when :mysql
|
|
21
|
-
require 'trilogy'
|
|
22
|
-
parsed = parse_mysql_url(audit_url)
|
|
23
|
-
Trilogy.new(
|
|
24
|
-
host: parsed[:host],
|
|
25
|
-
port: parsed[:port] || 3306,
|
|
26
|
-
username: parsed[:username],
|
|
27
|
-
password: parsed[:password],
|
|
28
|
-
database: parsed[:database],
|
|
29
|
-
ssl: parsed[:ssl],
|
|
30
|
-
)
|
|
31
|
-
else
|
|
32
|
-
raise ConfigurationError, 'Unsupported database type for connection'
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def detect_database_type(url)
|
|
37
|
-
return Chronicles.config.adapter unless url
|
|
38
|
-
return :postgresql if url.start_with?('postgres://', 'postgresql://')
|
|
39
|
-
return :mysql if url.start_with?('mysql://', 'mysql2://')
|
|
40
|
-
|
|
41
|
-
# Fallback to configured adapter
|
|
42
|
-
Chronicles.config.adapter
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def parse_mysql_url(url)
|
|
46
|
-
return {} if url.nil? || url.empty?
|
|
47
|
-
|
|
48
|
-
uri = URI.parse(url)
|
|
49
|
-
{
|
|
50
|
-
host: uri.host,
|
|
51
|
-
port: uri.port,
|
|
52
|
-
username: uri.user,
|
|
53
|
-
password: uri.password,
|
|
54
|
-
database: uri.path&.sub('/', ''),
|
|
55
|
-
ssl: uri.query&.include?('ssl=true'),
|
|
56
|
-
}
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def connection_active?
|
|
60
|
-
case detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
61
|
-
when :postgresql
|
|
62
|
-
@connection && !@connection.finished?
|
|
63
|
-
when :mysql
|
|
64
|
-
@connection&.ping
|
|
65
|
-
else
|
|
66
|
-
false
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def setup_connection_specifics
|
|
71
|
-
case detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
72
|
-
when :postgresql
|
|
73
|
-
@connection.type_map_for_results = PG::BasicTypeMapForResults.new(@connection)
|
|
74
|
-
when :mysql
|
|
75
|
-
# MySQL/Trilogy doesn't need special setup
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def ensure_connection
|
|
80
|
-
return if @connection && connection_active?
|
|
81
|
-
|
|
82
|
-
@connection = create_connection
|
|
83
|
-
setup_connection_specifics
|
|
84
|
-
ensure_table_exists
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Whodunit
|
|
4
|
-
module Chronicles
|
|
5
|
-
# Handles record persistence for different database adapters
|
|
6
|
-
#
|
|
7
|
-
# Provides adapter-specific SQL for inserting chronicle records
|
|
8
|
-
module Persistence
|
|
9
|
-
private
|
|
10
|
-
|
|
11
|
-
def persist_record(record)
|
|
12
|
-
db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
13
|
-
|
|
14
|
-
case db_type
|
|
15
|
-
when :postgresql
|
|
16
|
-
persist_record_postgresql(record)
|
|
17
|
-
when :mysql
|
|
18
|
-
persist_record_mysql(record)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def persist_record_postgresql(record)
|
|
23
|
-
sql = <<~SQL
|
|
24
|
-
INSERT INTO whodunit_chronicles_audits (
|
|
25
|
-
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
26
|
-
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
27
|
-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
28
|
-
RETURNING id
|
|
29
|
-
SQL
|
|
30
|
-
|
|
31
|
-
params = build_record_params(record)
|
|
32
|
-
result = @connection.exec_params(sql, params)
|
|
33
|
-
record[:id] = result.first['id'].to_i
|
|
34
|
-
result.clear
|
|
35
|
-
|
|
36
|
-
record
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def persist_record_mysql(record)
|
|
40
|
-
sql = <<~SQL
|
|
41
|
-
INSERT INTO whodunit_chronicles_audits (
|
|
42
|
-
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
43
|
-
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
44
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
45
|
-
SQL
|
|
46
|
-
|
|
47
|
-
params = build_record_params(record)
|
|
48
|
-
@connection.execute(sql, *params)
|
|
49
|
-
record[:id] = @connection.last_insert_id
|
|
50
|
-
|
|
51
|
-
record
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def persist_records_batch(records)
|
|
55
|
-
return records if records.empty?
|
|
56
|
-
|
|
57
|
-
db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
|
|
58
|
-
|
|
59
|
-
case db_type
|
|
60
|
-
when :postgresql
|
|
61
|
-
persist_records_batch_postgresql(records)
|
|
62
|
-
when :mysql
|
|
63
|
-
persist_records_batch_mysql(records)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def persist_records_batch_postgresql(records)
|
|
68
|
-
# Use multi-row INSERT for better performance
|
|
69
|
-
values_clauses = []
|
|
70
|
-
all_params = []
|
|
71
|
-
param_index = 1
|
|
72
|
-
|
|
73
|
-
records.each do |record|
|
|
74
|
-
param_positions = (param_index..(param_index + 13)).map { |i| "$#{i}" }.join(', ')
|
|
75
|
-
values_clauses << "(#{param_positions})"
|
|
76
|
-
all_params.concat(build_record_params(record))
|
|
77
|
-
param_index += 14
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
sql = <<~SQL
|
|
81
|
-
INSERT INTO whodunit_chronicles_audits (
|
|
82
|
-
table_name, schema_name, record_id, action, old_data, new_data, changes,
|
|
83
|
-
user_id, user_type, transaction_id, sequence_number, occurred_at, created_at, metadata
|
|
84
|
-
) VALUES #{values_clauses.join(', ')}
|
|
85
|
-
RETURNING id
|
|
86
|
-
SQL
|
|
87
|
-
|
|
88
|
-
result = @connection.exec_params(sql, all_params)
|
|
89
|
-
|
|
90
|
-
# Set IDs on the records
|
|
91
|
-
result.each_with_index do |row, index|
|
|
92
|
-
records[index][:id] = row['id'].to_i
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
result.clear
|
|
96
|
-
records
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def persist_records_batch_mysql(records)
|
|
100
|
-
# For MySQL, we'll use individual inserts in a transaction for simplicity
|
|
101
|
-
# A more optimized version could use VALUES() with multiple rows
|
|
102
|
-
records.each do |record|
|
|
103
|
-
persist_record_mysql(record)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
records
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def build_record_params(record)
|
|
110
|
-
[
|
|
111
|
-
record[:table_name],
|
|
112
|
-
record[:schema_name],
|
|
113
|
-
record[:record_id].to_json,
|
|
114
|
-
record[:action],
|
|
115
|
-
record[:old_data]&.to_json,
|
|
116
|
-
record[:new_data]&.to_json,
|
|
117
|
-
record[:changes].to_json,
|
|
118
|
-
record[:user_id],
|
|
119
|
-
record[:user_type],
|
|
120
|
-
record[:transaction_id],
|
|
121
|
-
record[:sequence_number],
|
|
122
|
-
record[:occurred_at],
|
|
123
|
-
record[:created_at],
|
|
124
|
-
record[:metadata].to_json,
|
|
125
|
-
]
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|