pg_sql_triggers 1.0.1 → 1.1.1
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 +88 -0
- data/COVERAGE.md +58 -0
- data/Goal.md +180 -138
- data/README.md +8 -2
- data/RELEASE.md +1 -1
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
- data/app/controllers/pg_sql_triggers/generator_controller.rb +67 -5
- data/app/models/pg_sql_triggers/trigger_registry.rb +73 -10
- data/app/views/pg_sql_triggers/generator/new.html.erb +18 -0
- data/app/views/pg_sql_triggers/generator/preview.html.erb +233 -13
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +32 -0
- data/config/initializers/pg_sql_triggers.rb +69 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +2 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/README.md +2 -2
- data/docs/api-reference.md +22 -4
- data/docs/getting-started.md +1 -1
- data/docs/kill-switch.md +3 -3
- data/docs/usage-guide.md +73 -0
- data/docs/web-ui.md +14 -0
- data/lib/generators/pg_sql_triggers/templates/README +1 -1
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +2 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +8 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
- data/lib/pg_sql_triggers/drift/detector.rb +187 -0
- data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
- data/lib/pg_sql_triggers/drift.rb +14 -11
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
- data/lib/pg_sql_triggers/generator/form.rb +3 -1
- data/lib/pg_sql_triggers/generator/service.rb +81 -25
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
- data/lib/pg_sql_triggers/migrator.rb +58 -0
- data/lib/pg_sql_triggers/registry/manager.rb +96 -9
- data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +12 -0
- data/scripts/generate_coverage_report.rb +129 -0
- metadata +19 -12
|
@@ -12,6 +12,7 @@ module PgSqlTriggers
|
|
|
12
12
|
@enabled = false
|
|
13
13
|
@environments = []
|
|
14
14
|
@condition = nil
|
|
15
|
+
@timing = "before"
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def table(table_name)
|
|
@@ -50,6 +51,18 @@ module PgSqlTriggers
|
|
|
50
51
|
@condition = condition_sql
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
def timing(timing_value = nil)
|
|
55
|
+
if timing_value.nil?
|
|
56
|
+
@timing
|
|
57
|
+
else
|
|
58
|
+
@timing = timing_value.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def function_body
|
|
63
|
+
nil # DSL definitions don't include function_body directly
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
def to_h
|
|
54
67
|
{
|
|
55
68
|
name: @name,
|
|
@@ -59,7 +72,8 @@ module PgSqlTriggers
|
|
|
59
72
|
version: @version,
|
|
60
73
|
enabled: @enabled,
|
|
61
74
|
environments: @environments,
|
|
62
|
-
condition: @condition
|
|
75
|
+
condition: @condition,
|
|
76
|
+
timing: @timing
|
|
63
77
|
}
|
|
64
78
|
end
|
|
65
79
|
end
|
|
@@ -6,7 +6,7 @@ module PgSqlTriggers
|
|
|
6
6
|
include ActiveModel::Model
|
|
7
7
|
|
|
8
8
|
attr_accessor :trigger_name, :table_name, :function_name,
|
|
9
|
-
:version, :enabled, :condition,
|
|
9
|
+
:version, :enabled, :condition, :timing,
|
|
10
10
|
:generate_function_stub, :events, :environments,
|
|
11
11
|
:function_body
|
|
12
12
|
|
|
@@ -23,6 +23,7 @@ module PgSqlTriggers
|
|
|
23
23
|
}
|
|
24
24
|
validates :version, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
25
25
|
validates :function_body, presence: true
|
|
26
|
+
validates :timing, inclusion: { in: %w[before after], message: "must be 'before' or 'after'" }
|
|
26
27
|
validate :at_least_one_event
|
|
27
28
|
validate :function_name_matches_body
|
|
28
29
|
|
|
@@ -38,6 +39,7 @@ module PgSqlTriggers
|
|
|
38
39
|
@generate_function_stub = true if @generate_function_stub.nil?
|
|
39
40
|
@events ||= []
|
|
40
41
|
@environments ||= []
|
|
42
|
+
@timing ||= "before" # Default to "before" if not specified
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def default_function_body
|
|
@@ -6,6 +6,7 @@ require "active_support/core_ext/string/inflections"
|
|
|
6
6
|
|
|
7
7
|
module PgSqlTriggers
|
|
8
8
|
module Generator
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
10
|
class Service
|
|
10
11
|
class << self
|
|
11
12
|
def generate_dsl(form)
|
|
@@ -31,6 +32,7 @@ module PgSqlTriggers
|
|
|
31
32
|
#{' '}
|
|
32
33
|
version #{form.version}
|
|
33
34
|
enabled #{form.enabled}
|
|
35
|
+
timing :#{form.timing || 'before'}
|
|
34
36
|
RUBY
|
|
35
37
|
|
|
36
38
|
code += " when_env #{environments_list}\n" if form.environments.compact_blank.any?
|
|
@@ -51,8 +53,9 @@ module PgSqlTriggers
|
|
|
51
53
|
function_body_sql = form.function_body.strip
|
|
52
54
|
|
|
53
55
|
# Build the trigger creation SQL
|
|
56
|
+
timing_value = (form.timing || "before").upcase
|
|
54
57
|
trigger_sql = "CREATE TRIGGER #{form.trigger_name}\n"
|
|
55
|
-
trigger_sql += "
|
|
58
|
+
trigger_sql += "#{timing_value} #{events_sql} ON #{form.table_name}\n"
|
|
56
59
|
trigger_sql += "FOR EACH ROW\n"
|
|
57
60
|
trigger_sql += "WHEN (#{form.condition})\n" if form.condition.present?
|
|
58
61
|
trigger_sql += "EXECUTE FUNCTION #{form.function_name}();"
|
|
@@ -61,6 +64,11 @@ module PgSqlTriggers
|
|
|
61
64
|
down_sql = "DROP TRIGGER IF EXISTS #{form.trigger_name} ON #{form.table_name};\n"
|
|
62
65
|
down_sql += "DROP FUNCTION IF EXISTS #{form.function_name}();"
|
|
63
66
|
|
|
67
|
+
# Indent SQL strings to match heredoc indentation (18 spaces)
|
|
68
|
+
indented_function_body = indent_sql(function_body_sql, 18)
|
|
69
|
+
indented_trigger_sql = indent_sql(trigger_sql, 18)
|
|
70
|
+
indented_down_sql = indent_sql(down_sql, 18)
|
|
71
|
+
|
|
64
72
|
<<~RUBY
|
|
65
73
|
# frozen_string_literal: true
|
|
66
74
|
|
|
@@ -69,18 +77,18 @@ module PgSqlTriggers
|
|
|
69
77
|
def up
|
|
70
78
|
# Create the function
|
|
71
79
|
execute <<-SQL
|
|
72
|
-
#{
|
|
80
|
+
#{indented_function_body}
|
|
73
81
|
SQL
|
|
74
82
|
|
|
75
83
|
# Create the trigger
|
|
76
84
|
execute <<-SQL
|
|
77
|
-
#{
|
|
85
|
+
#{indented_trigger_sql}
|
|
78
86
|
SQL
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
def down
|
|
82
90
|
execute <<-SQL
|
|
83
|
-
#{
|
|
91
|
+
#{indented_down_sql}
|
|
84
92
|
SQL
|
|
85
93
|
end
|
|
86
94
|
end
|
|
@@ -142,29 +150,53 @@ module PgSqlTriggers
|
|
|
142
150
|
|
|
143
151
|
def create_trigger(form, actor: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
144
152
|
paths = file_paths(form)
|
|
153
|
+
base_path = rails_base_path
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
create_trigger_files(form, paths, base_path)
|
|
156
|
+
registry = register_trigger(form)
|
|
157
|
+
|
|
158
|
+
build_success_response(registry, paths, form)
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
log_error(e)
|
|
161
|
+
build_error_response(e)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def indent_sql(sql_string, indent_level)
|
|
167
|
+
indent = " " * indent_level
|
|
168
|
+
sql_string.lines.map do |line|
|
|
169
|
+
stripped = line.chomp
|
|
170
|
+
stripped.empty? ? "" : indent + stripped
|
|
171
|
+
end.join("\n")
|
|
172
|
+
end
|
|
148
173
|
|
|
174
|
+
def rails_base_path
|
|
175
|
+
defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def create_trigger_files(form, paths, base_path)
|
|
149
179
|
full_migration_path = base_path.join(paths[:migration])
|
|
150
180
|
full_dsl_path = base_path.join(paths[:dsl])
|
|
151
181
|
|
|
152
|
-
# Create directories
|
|
153
182
|
FileUtils.mkdir_p(full_migration_path.dirname)
|
|
154
183
|
FileUtils.mkdir_p(full_dsl_path.dirname)
|
|
155
184
|
|
|
156
|
-
# Generate content
|
|
157
185
|
migration_content = generate_migration(form)
|
|
158
186
|
dsl_content = generate_dsl(form)
|
|
159
|
-
# Use function_body (required field)
|
|
160
|
-
function_content = form.function_body
|
|
161
187
|
|
|
162
|
-
# Write both files
|
|
163
188
|
File.write(full_migration_path, migration_content)
|
|
164
189
|
File.write(full_dsl_path, dsl_content)
|
|
190
|
+
end
|
|
165
191
|
|
|
166
|
-
|
|
167
|
-
definition =
|
|
192
|
+
def register_trigger(form)
|
|
193
|
+
definition = build_trigger_definition(form)
|
|
194
|
+
attributes = build_registry_attributes(form, definition)
|
|
195
|
+
TriggerRegistry.create!(attributes)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_trigger_definition(form)
|
|
199
|
+
{
|
|
168
200
|
name: form.trigger_name,
|
|
169
201
|
table_name: form.table_name,
|
|
170
202
|
events: form.events.compact_blank,
|
|
@@ -172,9 +204,13 @@ module PgSqlTriggers
|
|
|
172
204
|
version: form.version,
|
|
173
205
|
enabled: form.enabled,
|
|
174
206
|
environments: form.environments.compact_blank,
|
|
175
|
-
condition: form.condition
|
|
207
|
+
condition: form.condition,
|
|
208
|
+
timing: form.timing || "before",
|
|
209
|
+
function_body: form.function_body
|
|
176
210
|
}
|
|
211
|
+
end
|
|
177
212
|
|
|
213
|
+
def build_registry_attributes(form, definition)
|
|
178
214
|
attributes = {
|
|
179
215
|
trigger_name: form.trigger_name,
|
|
180
216
|
table_name: form.table_name,
|
|
@@ -183,15 +219,22 @@ module PgSqlTriggers
|
|
|
183
219
|
source: "dsl",
|
|
184
220
|
environment: form.environments.compact_blank.join(",").presence,
|
|
185
221
|
definition: definition.to_json,
|
|
186
|
-
function_body:
|
|
222
|
+
function_body: form.function_body,
|
|
187
223
|
checksum: calculate_checksum(definition)
|
|
188
224
|
}
|
|
189
225
|
|
|
190
|
-
|
|
191
|
-
attributes
|
|
226
|
+
add_conditional_attributes(attributes, form)
|
|
227
|
+
attributes
|
|
228
|
+
end
|
|
192
229
|
|
|
193
|
-
|
|
230
|
+
def add_conditional_attributes(attributes, form)
|
|
231
|
+
column_names = TriggerRegistry.column_names
|
|
194
232
|
|
|
233
|
+
attributes[:condition] = form.condition.presence if column_names.include?("condition")
|
|
234
|
+
attributes[:timing] = (form.timing || "before") if column_names.include?("timing")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def build_success_response(registry, paths, form)
|
|
195
238
|
{
|
|
196
239
|
success: true,
|
|
197
240
|
registry_id: registry.id,
|
|
@@ -204,18 +247,22 @@ module PgSqlTriggers
|
|
|
204
247
|
files_created: [paths[:migration], paths[:dsl]]
|
|
205
248
|
}
|
|
206
249
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def log_error(error)
|
|
253
|
+
return unless defined?(Rails)
|
|
254
|
+
|
|
255
|
+
Rails.logger.error("Trigger generation failed: #{error.message}")
|
|
256
|
+
Rails.logger.error(error.backtrace.join("\n"))
|
|
257
|
+
end
|
|
210
258
|
|
|
259
|
+
def build_error_response(error)
|
|
211
260
|
{
|
|
212
261
|
success: false,
|
|
213
|
-
error:
|
|
262
|
+
error: error.message
|
|
214
263
|
}
|
|
215
264
|
end
|
|
216
265
|
|
|
217
|
-
private
|
|
218
|
-
|
|
219
266
|
def next_migration_number
|
|
220
267
|
# Determine if we're in a Rails app context or standalone gem
|
|
221
268
|
base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
|
|
@@ -243,9 +290,18 @@ module PgSqlTriggers
|
|
|
243
290
|
end
|
|
244
291
|
|
|
245
292
|
def calculate_checksum(definition)
|
|
246
|
-
|
|
293
|
+
# Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
|
|
294
|
+
Digest::SHA256.hexdigest([
|
|
295
|
+
definition[:name],
|
|
296
|
+
definition[:table_name],
|
|
297
|
+
definition[:version],
|
|
298
|
+
definition[:function_body] || "",
|
|
299
|
+
definition[:condition] || "",
|
|
300
|
+
definition[:timing] || "before"
|
|
301
|
+
].join)
|
|
247
302
|
end
|
|
248
303
|
end
|
|
249
304
|
end
|
|
305
|
+
# rubocop:enable Metrics/ClassLength
|
|
250
306
|
end
|
|
251
307
|
end
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../drift/db_queries"
|
|
4
|
+
|
|
5
|
+
module PgSqlTriggers
|
|
6
|
+
class Migrator
|
|
7
|
+
# Pre-apply comparator that extracts expected SQL from migrations
|
|
8
|
+
# and compares it with the current database state
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
10
|
+
class PreApplyComparator
|
|
11
|
+
class << self
|
|
12
|
+
# Compare expected state from migration with actual database state
|
|
13
|
+
# Returns a comparison result with diff information
|
|
14
|
+
def compare(migration_instance, direction: :up)
|
|
15
|
+
expected = extract_expected_state(migration_instance, direction)
|
|
16
|
+
actual = extract_actual_state(expected)
|
|
17
|
+
generate_diff(expected, actual)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Extract expected SQL and state from migration instance
|
|
23
|
+
def extract_expected_state(migration_instance, direction)
|
|
24
|
+
captured_sql = capture_sql(migration_instance, direction)
|
|
25
|
+
parse_sql_to_state(captured_sql)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Capture SQL that would be executed by the migration
|
|
29
|
+
def capture_sql(migration_instance, direction)
|
|
30
|
+
captured = []
|
|
31
|
+
|
|
32
|
+
# Override execute to capture SQL instead of executing
|
|
33
|
+
# Since we use a separate instance for comparison, we don't need to restore
|
|
34
|
+
migration_instance.define_singleton_method(:execute) do |sql|
|
|
35
|
+
captured << sql.to_s.strip
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Call the migration method (up or down) to capture SQL
|
|
39
|
+
migration_instance.public_send(direction)
|
|
40
|
+
|
|
41
|
+
captured
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parse captured SQL into structured state (triggers, functions)
|
|
45
|
+
def parse_sql_to_state(sql_array)
|
|
46
|
+
state = {
|
|
47
|
+
functions: [],
|
|
48
|
+
triggers: []
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
sql_array.each do |sql|
|
|
52
|
+
sql_normalized = sql.squish
|
|
53
|
+
|
|
54
|
+
# Parse CREATE FUNCTION statements
|
|
55
|
+
if sql_normalized.match?(/CREATE\s+(OR\s+REPLACE\s+)?FUNCTION/i)
|
|
56
|
+
function_info = parse_function_sql(sql)
|
|
57
|
+
state[:functions] << function_info if function_info
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Parse CREATE TRIGGER statements
|
|
61
|
+
if sql_normalized.match?(/CREATE\s+TRIGGER/i)
|
|
62
|
+
trigger_info = parse_trigger_sql(sql)
|
|
63
|
+
state[:triggers] << trigger_info if trigger_info
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Parse DROP statements (for down migrations)
|
|
67
|
+
next unless sql_normalized.match?(/DROP\s+(TRIGGER|FUNCTION)/i)
|
|
68
|
+
|
|
69
|
+
drop_info = parse_drop_sql(sql)
|
|
70
|
+
state[:drops] ||= []
|
|
71
|
+
state[:drops] << drop_info if drop_info
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
state
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Parse function SQL to extract function name and body
|
|
78
|
+
def parse_function_sql(sql)
|
|
79
|
+
# Match CREATE [OR REPLACE] FUNCTION function_name(...) ... AS $$ body $$
|
|
80
|
+
match = sql.match(/CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(\w+)\s*\([^)]*\)/i)
|
|
81
|
+
return nil unless match
|
|
82
|
+
|
|
83
|
+
function_name = match[1]
|
|
84
|
+
|
|
85
|
+
# Extract function body (between $$ markers or AS ...)
|
|
86
|
+
body_match = sql.match(/\$\$(.+?)\$\$/m) || sql.match(/AS\s+(.+)/im)
|
|
87
|
+
function_body = body_match ? body_match[1].strip : sql
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
function_name: function_name,
|
|
91
|
+
function_body: function_body,
|
|
92
|
+
full_sql: sql
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Parse trigger SQL to extract trigger details
|
|
97
|
+
def parse_trigger_sql(sql)
|
|
98
|
+
# Match CREATE TRIGGER trigger_name BEFORE/AFTER events ON table_name ...
|
|
99
|
+
match = sql.match(/CREATE\s+TRIGGER\s+(\w+)\s+(BEFORE|AFTER)\s+(.+?)\s+ON\s+(\w+)/i)
|
|
100
|
+
return nil unless match
|
|
101
|
+
|
|
102
|
+
trigger_name = match[1]
|
|
103
|
+
timing = match[2]
|
|
104
|
+
events = match[3].strip.split(/\s+OR\s+/i).map(&:strip)
|
|
105
|
+
table_name = match[4]
|
|
106
|
+
|
|
107
|
+
# Extract WHEN condition if present
|
|
108
|
+
condition_match = sql.match(/WHEN\s+\(([^)]+)\)/i)
|
|
109
|
+
condition = condition_match ? condition_match[1].strip : nil
|
|
110
|
+
|
|
111
|
+
# Extract function name
|
|
112
|
+
function_match = sql.match(/EXECUTE\s+FUNCTION\s+(\w+)\s*\(\)/i)
|
|
113
|
+
function_name = function_match ? function_match[1] : nil
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
trigger_name: trigger_name,
|
|
117
|
+
table_name: table_name,
|
|
118
|
+
timing: timing,
|
|
119
|
+
events: events,
|
|
120
|
+
condition: condition,
|
|
121
|
+
function_name: function_name,
|
|
122
|
+
full_sql: sql
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Parse DROP SQL statements
|
|
127
|
+
def parse_drop_sql(sql)
|
|
128
|
+
if sql.match?(/DROP\s+TRIGGER/i)
|
|
129
|
+
match = sql.match(/DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?(\w+)\s+ON\s+(\w+)/i)
|
|
130
|
+
return nil unless match
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
type: :trigger,
|
|
134
|
+
name: match[1],
|
|
135
|
+
table_name: match[2]
|
|
136
|
+
}
|
|
137
|
+
elsif sql.match?(/DROP\s+FUNCTION/i)
|
|
138
|
+
match = sql.match(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(\w+)\s*\(\)/i)
|
|
139
|
+
return nil unless match
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
type: :function,
|
|
143
|
+
name: match[1]
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Extract actual state from database
|
|
149
|
+
def extract_actual_state(expected)
|
|
150
|
+
actual = {
|
|
151
|
+
functions: {},
|
|
152
|
+
triggers: {}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# Get actual functions from database
|
|
156
|
+
expected[:functions].each do |expected_func|
|
|
157
|
+
db_func = Drift::DbQueries.find_function(expected_func[:function_name])
|
|
158
|
+
actual[:functions][expected_func[:function_name]] = if db_func
|
|
159
|
+
{
|
|
160
|
+
function_name: db_func["function_name"],
|
|
161
|
+
function_body: db_func["function_definition"],
|
|
162
|
+
exists: true
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
{
|
|
166
|
+
function_name: expected_func[:function_name],
|
|
167
|
+
exists: false
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Get actual triggers from database
|
|
173
|
+
expected[:triggers].each do |expected_trigger|
|
|
174
|
+
db_trigger = Drift::DbQueries.find_trigger(expected_trigger[:trigger_name])
|
|
175
|
+
if db_trigger
|
|
176
|
+
actual[:triggers][expected_trigger[:trigger_name]] = {
|
|
177
|
+
trigger_name: db_trigger["trigger_name"],
|
|
178
|
+
table_name: db_trigger["table_name"],
|
|
179
|
+
function_name: db_trigger["function_name"],
|
|
180
|
+
trigger_definition: db_trigger["trigger_definition"],
|
|
181
|
+
function_definition: db_trigger["function_definition"],
|
|
182
|
+
exists: true
|
|
183
|
+
}
|
|
184
|
+
else
|
|
185
|
+
actual[:triggers][expected_trigger[:trigger_name]] = {
|
|
186
|
+
trigger_name: expected_trigger[:trigger_name],
|
|
187
|
+
table_name: expected_trigger[:table_name],
|
|
188
|
+
exists: false
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
actual
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Generate diff between expected and actual state
|
|
197
|
+
# rubocop:disable Metrics/MethodLength
|
|
198
|
+
def generate_diff(expected, actual)
|
|
199
|
+
diff = {
|
|
200
|
+
has_differences: false,
|
|
201
|
+
functions: [],
|
|
202
|
+
triggers: [],
|
|
203
|
+
drops: expected[:drops] || []
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Compare functions
|
|
207
|
+
expected[:functions].each do |expected_func|
|
|
208
|
+
func_name = expected_func[:function_name]
|
|
209
|
+
actual_func = actual[:functions][func_name]
|
|
210
|
+
|
|
211
|
+
if !actual_func || !actual_func[:exists]
|
|
212
|
+
diff[:functions] << {
|
|
213
|
+
function_name: func_name,
|
|
214
|
+
status: :new,
|
|
215
|
+
expected: expected_func[:function_body],
|
|
216
|
+
actual: nil,
|
|
217
|
+
message: "Function will be created"
|
|
218
|
+
}
|
|
219
|
+
diff[:has_differences] = true
|
|
220
|
+
elsif actual_func[:function_body] != expected_func[:function_body]
|
|
221
|
+
diff[:functions] << {
|
|
222
|
+
function_name: func_name,
|
|
223
|
+
status: :modified,
|
|
224
|
+
expected: expected_func[:function_body],
|
|
225
|
+
actual: actual_func[:function_body],
|
|
226
|
+
message: "Function body differs from expected"
|
|
227
|
+
}
|
|
228
|
+
diff[:has_differences] = true
|
|
229
|
+
else
|
|
230
|
+
diff[:functions] << {
|
|
231
|
+
function_name: func_name,
|
|
232
|
+
status: :unchanged,
|
|
233
|
+
message: "Function matches expected state"
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Compare triggers
|
|
239
|
+
expected[:triggers].each do |expected_trigger|
|
|
240
|
+
trigger_name = expected_trigger[:trigger_name]
|
|
241
|
+
actual_trigger = actual[:triggers][trigger_name]
|
|
242
|
+
|
|
243
|
+
if !actual_trigger || !actual_trigger[:exists]
|
|
244
|
+
diff[:triggers] << {
|
|
245
|
+
trigger_name: trigger_name,
|
|
246
|
+
status: :new,
|
|
247
|
+
expected: expected_trigger[:full_sql],
|
|
248
|
+
actual: nil,
|
|
249
|
+
message: "Trigger will be created"
|
|
250
|
+
}
|
|
251
|
+
diff[:has_differences] = true
|
|
252
|
+
else
|
|
253
|
+
# Compare trigger definitions
|
|
254
|
+
expected_def = normalize_trigger_definition(expected_trigger)
|
|
255
|
+
actual_def = normalize_trigger_definition_from_db(actual_trigger)
|
|
256
|
+
|
|
257
|
+
if expected_def == actual_def
|
|
258
|
+
diff[:triggers] << {
|
|
259
|
+
trigger_name: trigger_name,
|
|
260
|
+
status: :unchanged,
|
|
261
|
+
message: "Trigger matches expected state"
|
|
262
|
+
}
|
|
263
|
+
else
|
|
264
|
+
diff[:triggers] << {
|
|
265
|
+
trigger_name: trigger_name,
|
|
266
|
+
status: :modified,
|
|
267
|
+
expected: expected_trigger[:full_sql],
|
|
268
|
+
actual: actual_trigger[:trigger_definition],
|
|
269
|
+
message: "Trigger definition differs from expected",
|
|
270
|
+
differences: compare_trigger_details(expected_trigger, actual_trigger)
|
|
271
|
+
}
|
|
272
|
+
diff[:has_differences] = true
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
diff
|
|
278
|
+
end
|
|
279
|
+
# rubocop:enable Metrics/MethodLength
|
|
280
|
+
|
|
281
|
+
# Normalize trigger definition for comparison
|
|
282
|
+
def normalize_trigger_definition(trigger)
|
|
283
|
+
{
|
|
284
|
+
trigger_name: trigger[:trigger_name],
|
|
285
|
+
table_name: trigger[:table_name],
|
|
286
|
+
events: trigger[:events].sort,
|
|
287
|
+
condition: trigger[:condition],
|
|
288
|
+
function_name: trigger[:function_name]
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Normalize trigger definition from database for comparison
|
|
293
|
+
def normalize_trigger_definition_from_db(db_trigger)
|
|
294
|
+
# Parse trigger definition from pg_get_triggerdef output
|
|
295
|
+
def_str = db_trigger[:trigger_definition] || ""
|
|
296
|
+
|
|
297
|
+
# Extract events, condition, etc. from definition string
|
|
298
|
+
events_match = def_str.match(/BEFORE\s+(.+?)\s+ON/i) || def_str.match(/AFTER\s+(.+?)\s+ON/i)
|
|
299
|
+
events = events_match ? events_match[1].split(/\s+OR\s+/i).map(&:strip).sort : []
|
|
300
|
+
|
|
301
|
+
condition_match = def_str.match(/WHEN\s+\(([^)]+)\)/i)
|
|
302
|
+
condition = condition_match ? condition_match[1].strip : nil
|
|
303
|
+
|
|
304
|
+
{
|
|
305
|
+
trigger_name: db_trigger[:trigger_name],
|
|
306
|
+
table_name: db_trigger[:table_name],
|
|
307
|
+
events: events,
|
|
308
|
+
condition: condition,
|
|
309
|
+
function_name: db_trigger[:function_name]
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Compare trigger details to find specific differences
|
|
314
|
+
def compare_trigger_details(expected, actual_db_trigger)
|
|
315
|
+
differences = []
|
|
316
|
+
|
|
317
|
+
# Normalize actual trigger for comparison
|
|
318
|
+
actual = normalize_trigger_definition_from_db(actual_db_trigger)
|
|
319
|
+
|
|
320
|
+
if expected[:table_name] != actual[:table_name]
|
|
321
|
+
differences << "Table name: expected '#{expected[:table_name]}', actual '#{actual[:table_name]}'"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
expected_events = expected[:events].sort
|
|
325
|
+
actual_events = actual[:events] || []
|
|
326
|
+
if expected_events != actual_events.sort
|
|
327
|
+
differences << "Events: expected #{expected_events.inspect}, actual #{actual_events.inspect}"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
if expected[:condition] != actual[:condition]
|
|
331
|
+
differences << "Condition: expected '#{expected[:condition]}', actual '#{actual[:condition]}'"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
if expected[:function_name] != actual[:function_name]
|
|
335
|
+
differences << "Function: expected '#{expected[:function_name]}', actual '#{actual[:function_name]}'"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
differences
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
# rubocop:enable Metrics/ClassLength
|
|
343
|
+
end
|
|
344
|
+
end
|