pg_sql_triggers 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +47 -0
  3. data/.rubocop.yml +4 -1
  4. data/CHANGELOG.md +112 -1
  5. data/COVERAGE.md +58 -0
  6. data/Goal.md +450 -123
  7. data/README.md +53 -215
  8. data/app/controllers/pg_sql_triggers/application_controller.rb +46 -0
  9. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
  10. data/app/controllers/pg_sql_triggers/generator_controller.rb +76 -8
  11. data/app/controllers/pg_sql_triggers/migrations_controller.rb +18 -0
  12. data/app/models/pg_sql_triggers/trigger_registry.rb +93 -12
  13. data/app/views/layouts/pg_sql_triggers/application.html.erb +34 -1
  14. data/app/views/pg_sql_triggers/dashboard/index.html.erb +70 -30
  15. data/app/views/pg_sql_triggers/generator/new.html.erb +22 -4
  16. data/app/views/pg_sql_triggers/generator/preview.html.erb +244 -16
  17. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +221 -0
  18. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +40 -0
  19. data/app/views/pg_sql_triggers/tables/index.html.erb +0 -2
  20. data/app/views/pg_sql_triggers/tables/show.html.erb +3 -4
  21. data/config/initializers/pg_sql_triggers.rb +69 -0
  22. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +3 -1
  23. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
  24. data/docs/README.md +66 -0
  25. data/docs/api-reference.md +681 -0
  26. data/docs/configuration.md +541 -0
  27. data/docs/getting-started.md +135 -0
  28. data/docs/kill-switch.md +586 -0
  29. data/docs/screenshots/.gitkeep +1 -0
  30. data/docs/screenshots/Generate Trigger.png +0 -0
  31. data/docs/screenshots/Triggers Page.png +0 -0
  32. data/docs/screenshots/kill error.png +0 -0
  33. data/docs/screenshots/kill modal for migration down.png +0 -0
  34. data/docs/usage-guide.md +493 -0
  35. data/docs/web-ui.md +353 -0
  36. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +3 -1
  37. data/lib/generators/pg_sql_triggers/templates/initializer.rb +44 -2
  38. data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
  39. data/lib/pg_sql_triggers/drift/detector.rb +187 -0
  40. data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
  41. data/lib/pg_sql_triggers/drift.rb +14 -11
  42. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
  43. data/lib/pg_sql_triggers/generator/form.rb +3 -1
  44. data/lib/pg_sql_triggers/generator/service.rb +82 -26
  45. data/lib/pg_sql_triggers/migration.rb +1 -1
  46. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
  47. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
  48. data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
  49. data/lib/pg_sql_triggers/migrator.rb +85 -3
  50. data/lib/pg_sql_triggers/registry/manager.rb +100 -13
  51. data/lib/pg_sql_triggers/sql/kill_switch.rb +300 -0
  52. data/lib/pg_sql_triggers/testing/dry_run.rb +5 -7
  53. data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
  54. data/lib/pg_sql_triggers/testing/safe_executor.rb +23 -11
  55. data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
  56. data/lib/pg_sql_triggers/version.rb +1 -1
  57. data/lib/pg_sql_triggers.rb +24 -0
  58. data/lib/tasks/trigger_migrations.rake +33 -0
  59. data/scripts/generate_coverage_report.rb +129 -0
  60. metadata +45 -5
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "db_queries"
5
+
6
+ module PgSqlTriggers
7
+ module Drift
8
+ class Detector
9
+ class << self
10
+ # Detect drift for a single trigger
11
+ def detect(trigger_name)
12
+ registry_entry = TriggerRegistry.find_by(trigger_name: trigger_name)
13
+ db_trigger = DbQueries.find_trigger(trigger_name)
14
+
15
+ # State 1: DISABLED - Registry entry disabled
16
+ return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
17
+
18
+ # State 2: MANUAL_OVERRIDE - Marked as manual SQL
19
+ return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
20
+
21
+ # State 3: DROPPED - Registry entry exists, DB trigger missing
22
+ return dropped_state(registry_entry) if registry_entry && !db_trigger
23
+
24
+ # State 4: UNKNOWN - DB trigger exists, no registry entry
25
+ return unknown_state(db_trigger) if !registry_entry && db_trigger
26
+
27
+ # State 5: DRIFTED - Checksum mismatch
28
+ if registry_entry && db_trigger
29
+ checksum_match = checksums_match?(registry_entry, db_trigger)
30
+ return drifted_state(registry_entry, db_trigger) unless checksum_match
31
+ end
32
+
33
+ # State 6: IN_SYNC - Everything matches
34
+ in_sync_state(registry_entry, db_trigger)
35
+ end
36
+
37
+ # Detect drift for all triggers
38
+ def detect_all
39
+ registry_entries = TriggerRegistry.all.to_a
40
+ db_triggers = DbQueries.all_triggers
41
+
42
+ # Check each registry entry
43
+ results = registry_entries.map do |entry|
44
+ detect(entry.trigger_name)
45
+ end
46
+
47
+ # Find unknown (external) triggers not in registry
48
+ registry_trigger_names = registry_entries.map(&:trigger_name)
49
+ db_triggers.each do |db_trigger|
50
+ next if registry_trigger_names.include?(db_trigger["trigger_name"])
51
+
52
+ results << unknown_state(db_trigger)
53
+ end
54
+
55
+ results
56
+ end
57
+
58
+ # Detect drift for a specific table
59
+ def detect_for_table(table_name)
60
+ registry_entries = TriggerRegistry.for_table(table_name).to_a
61
+ db_triggers = DbQueries.find_triggers_for_table(table_name)
62
+
63
+ # Check each registry entry for this table
64
+ results = registry_entries.map do |entry|
65
+ detect(entry.trigger_name)
66
+ end
67
+
68
+ # Find unknown triggers on this table
69
+ registry_trigger_names = registry_entries.map(&:trigger_name)
70
+ db_triggers.each do |db_trigger|
71
+ next if registry_trigger_names.include?(db_trigger["trigger_name"])
72
+
73
+ results << unknown_state(db_trigger)
74
+ end
75
+
76
+ results
77
+ end
78
+
79
+ private
80
+
81
+ # Compare registry checksum with calculated DB checksum
82
+ def checksums_match?(registry_entry, db_trigger)
83
+ db_checksum = calculate_db_checksum(registry_entry, db_trigger)
84
+ registry_entry.checksum == db_checksum
85
+ end
86
+
87
+ # Calculate checksum from DB trigger (must match registry algorithm)
88
+ def calculate_db_checksum(registry_entry, db_trigger)
89
+ # Extract function body from the function definition
90
+ function_body = extract_function_body(db_trigger)
91
+
92
+ # Extract condition from trigger definition
93
+ condition = extract_trigger_condition(db_trigger)
94
+
95
+ # Use same algorithm as TriggerRegistry#calculate_checksum
96
+ Digest::SHA256.hexdigest([
97
+ registry_entry.trigger_name,
98
+ registry_entry.table_name,
99
+ registry_entry.version,
100
+ function_body || "",
101
+ condition || ""
102
+ ].join)
103
+ end
104
+
105
+ # Extract function body from pg_get_functiondef output
106
+ def extract_function_body(db_trigger)
107
+ function_def = db_trigger["function_definition"]
108
+ return nil unless function_def
109
+
110
+ # The function definition includes CREATE OR REPLACE FUNCTION header
111
+ # We need to extract just the body for comparison
112
+ # For now, return the full definition
113
+ # TODO: Parse and extract just the body if needed
114
+ function_def
115
+ end
116
+
117
+ # Extract WHEN condition from trigger definition
118
+ def extract_trigger_condition(db_trigger)
119
+ trigger_def = db_trigger["trigger_definition"]
120
+ return nil unless trigger_def
121
+
122
+ # Extract WHEN clause from trigger definition
123
+ # Example: "... WHEN ((new.email IS NOT NULL)) EXECUTE ..."
124
+ match = trigger_def.match(/WHEN\s+\((.+?)\)\s+EXECUTE/i)
125
+ match ? match[1].strip : nil
126
+ end
127
+
128
+ # State helper methods
129
+ def disabled_state(registry_entry, db_trigger)
130
+ {
131
+ state: PgSqlTriggers::DRIFT_STATE_DISABLED,
132
+ registry_entry: registry_entry,
133
+ db_trigger: db_trigger,
134
+ details: "Trigger is disabled in registry"
135
+ }
136
+ end
137
+
138
+ def manual_override_state(registry_entry, db_trigger)
139
+ {
140
+ state: PgSqlTriggers::DRIFT_STATE_MANUAL_OVERRIDE,
141
+ registry_entry: registry_entry,
142
+ db_trigger: db_trigger,
143
+ details: "Trigger marked as manual SQL override"
144
+ }
145
+ end
146
+
147
+ def dropped_state(registry_entry)
148
+ {
149
+ state: PgSqlTriggers::DRIFT_STATE_DROPPED,
150
+ registry_entry: registry_entry,
151
+ db_trigger: nil,
152
+ details: "Trigger exists in registry but not in database"
153
+ }
154
+ end
155
+
156
+ def unknown_state(db_trigger)
157
+ {
158
+ state: PgSqlTriggers::DRIFT_STATE_UNKNOWN,
159
+ registry_entry: nil,
160
+ db_trigger: db_trigger,
161
+ details: "Trigger exists in database but not in registry (external)"
162
+ }
163
+ end
164
+
165
+ def drifted_state(registry_entry, db_trigger)
166
+ {
167
+ state: PgSqlTriggers::DRIFT_STATE_DRIFTED,
168
+ registry_entry: registry_entry,
169
+ db_trigger: db_trigger,
170
+ checksum_match: false,
171
+ details: "Trigger has drifted (checksum mismatch between registry and database)"
172
+ }
173
+ end
174
+
175
+ def in_sync_state(registry_entry, db_trigger)
176
+ {
177
+ state: PgSqlTriggers::DRIFT_STATE_IN_SYNC,
178
+ registry_entry: registry_entry,
179
+ db_trigger: db_trigger,
180
+ checksum_match: true,
181
+ details: "Trigger matches registry (in sync)"
182
+ }
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "detector"
4
+
5
+ module PgSqlTriggers
6
+ module Drift
7
+ class Reporter
8
+ class << self
9
+ # Generate a summary report
10
+ def summary
11
+ results = Detector.detect_all
12
+
13
+ {
14
+ total: results.count,
15
+ in_sync: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_IN_SYNC },
16
+ drifted: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED },
17
+ disabled: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DISABLED },
18
+ dropped: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DROPPED },
19
+ unknown: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_UNKNOWN },
20
+ manual_override: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_MANUAL_OVERRIDE }
21
+ }
22
+ end
23
+
24
+ # Generate detailed report for a trigger
25
+ def report(trigger_name)
26
+ result = Detector.detect(trigger_name)
27
+
28
+ output = []
29
+ output << ("=" * 80)
30
+ output << "Drift Report: #{trigger_name}"
31
+ output << ("=" * 80)
32
+ output << ""
33
+
34
+ # State
35
+ output << "State: #{format_state(result[:state])}"
36
+ output << ""
37
+
38
+ # Details
39
+ output << "Details: #{result[:details]}"
40
+ output << ""
41
+
42
+ # Registry info
43
+ if result[:registry_entry]
44
+ output << "Registry Information:"
45
+ output << " Table: #{result[:registry_entry].table_name}"
46
+ output << " Version: #{result[:registry_entry].version}"
47
+ output << " Enabled: #{result[:registry_entry].enabled}"
48
+ output << " Source: #{result[:registry_entry].source}"
49
+ output << " Checksum: #{result[:registry_entry].checksum}"
50
+ output << ""
51
+ end
52
+
53
+ # Database info
54
+ if result[:db_trigger]
55
+ output << "Database Information:"
56
+ output << " Table: #{result[:db_trigger]['table_name']}"
57
+ output << " Function: #{result[:db_trigger]['function_name']}"
58
+ output << " Enabled: #{result[:db_trigger]['enabled']}"
59
+ output << ""
60
+ end
61
+
62
+ # If drifted, show diff
63
+ output << diff(trigger_name) if result[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED
64
+
65
+ output << ("=" * 80)
66
+
67
+ output.join("\n")
68
+ end
69
+
70
+ # Generate diff view (expected vs actual)
71
+ def diff(trigger_name)
72
+ result = Detector.detect(trigger_name)
73
+
74
+ return "No drift detected" if result[:state] != PgSqlTriggers::DRIFT_STATE_DRIFTED
75
+
76
+ output = []
77
+ output << "Drift Comparison:"
78
+ output << ("-" * 80)
79
+
80
+ registry_entry = result[:registry_entry]
81
+ db_trigger = result[:db_trigger]
82
+
83
+ # Version comparison
84
+ output << "Version:"
85
+ output << " Registry: #{registry_entry.version}"
86
+ output << " Database: (version not stored in DB)"
87
+ output << ""
88
+
89
+ # Checksum comparison
90
+ output << "Checksum:"
91
+ output << " Registry: #{registry_entry.checksum}"
92
+ output << " Database: (calculated from current DB state)"
93
+ output << ""
94
+
95
+ # Function comparison
96
+ output << "Function:"
97
+ output << " Registry Function Body:"
98
+ output << indent_text(registry_entry.function_body || "(not set)", 4)
99
+ output << ""
100
+ output << " Database Function Definition:"
101
+ output << indent_text(db_trigger["function_definition"] || "(not found)", 4)
102
+ output << ""
103
+
104
+ # Condition comparison
105
+ if registry_entry.respond_to?(:condition) && registry_entry.condition.present?
106
+ output << "Condition:"
107
+ output << " Registry: #{registry_entry.condition}"
108
+ # Extract condition from DB trigger definition
109
+ trigger_def = db_trigger["trigger_definition"]
110
+ db_condition = trigger_def.match(/WHEN\s+\((.+?)\)\s+EXECUTE/i)&.[](1)
111
+ output << " Database: #{db_condition || '(none)'}"
112
+ output << ""
113
+ end
114
+
115
+ output << ("-" * 80)
116
+
117
+ output.join("\n")
118
+ end
119
+
120
+ # Generate simple text list of drifted triggers
121
+ def drifted_list
122
+ results = Detector.detect_all
123
+ drifted = results.select { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED }
124
+
125
+ return "No drifted triggers found" if drifted.empty?
126
+
127
+ output = []
128
+ output << "Drifted Triggers (#{drifted.count}):"
129
+ output << ""
130
+
131
+ drifted.each do |result|
132
+ entry = result[:registry_entry]
133
+ output << " - #{entry.trigger_name} (#{entry.table_name})"
134
+ end
135
+
136
+ output.join("\n")
137
+ end
138
+
139
+ # Generate problematic triggers list (for dashboard)
140
+ def problematic_list
141
+ results = Detector.detect_all
142
+ results.select do |r|
143
+ [
144
+ PgSqlTriggers::DRIFT_STATE_DRIFTED,
145
+ PgSqlTriggers::DRIFT_STATE_DROPPED,
146
+ PgSqlTriggers::DRIFT_STATE_UNKNOWN
147
+ ].include?(r[:state])
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ def format_state(state)
154
+ case state
155
+ when PgSqlTriggers::DRIFT_STATE_IN_SYNC
156
+ "IN SYNC"
157
+ when PgSqlTriggers::DRIFT_STATE_DRIFTED
158
+ "DRIFTED"
159
+ when PgSqlTriggers::DRIFT_STATE_DROPPED
160
+ "DROPPED"
161
+ when PgSqlTriggers::DRIFT_STATE_UNKNOWN
162
+ "UNKNOWN (External)"
163
+ when PgSqlTriggers::DRIFT_STATE_DISABLED
164
+ "DISABLED"
165
+ when PgSqlTriggers::DRIFT_STATE_MANUAL_OVERRIDE
166
+ "MANUAL OVERRIDE"
167
+ else
168
+ "UNKNOWN STATE"
169
+ end
170
+ end
171
+
172
+ def indent_text(text, spaces)
173
+ indent = " " * spaces
174
+ text.to_s.lines.map { |line| "#{indent}#{line}" }.join
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -2,23 +2,26 @@
2
2
 
3
3
  module PgSqlTriggers
4
4
  module Drift
5
+ autoload :DbQueries, "pg_sql_triggers/drift/db_queries"
5
6
  autoload :Detector, "pg_sql_triggers/drift/detector"
6
7
  autoload :Reporter, "pg_sql_triggers/drift/reporter"
7
8
 
8
- # Drift states
9
- MANAGED_IN_SYNC = "managed_in_sync"
10
- MANAGED_DRIFTED = "managed_drifted"
11
- MANUAL_OVERRIDE = "manual_override"
12
- DISABLED = "disabled"
13
- DROPPED = "dropped"
14
- UNKNOWN = "unknown"
15
-
9
+ # Convenience method for detecting drift
16
10
  def self.detect(trigger_name = nil)
17
- Detector.detect(trigger_name)
11
+ if trigger_name
12
+ Detector.detect(trigger_name)
13
+ else
14
+ Detector.detect_all
15
+ end
16
+ end
17
+
18
+ # Convenience method for reporting
19
+ def self.summary
20
+ Reporter.summary
18
21
  end
19
22
 
20
- def self.report
21
- Reporter.report
23
+ def self.report(trigger_name)
24
+ Reporter.report(trigger_name)
22
25
  end
23
26
  end
24
27
  end
@@ -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 += "BEFORE #{events_sql} ON #{form.table_name}\n"
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
- #{function_body_sql}
80
+ #{indented_function_body}
73
81
  SQL
74
82
 
75
83
  # Create the trigger
76
84
  execute <<-SQL
77
- #{trigger_sql}
85
+ #{indented_trigger_sql}
78
86
  SQL
79
87
  end
80
88
 
81
89
  def down
82
90
  execute <<-SQL
83
- #{down_sql}
91
+ #{indented_down_sql}
84
92
  SQL
85
93
  end
86
94
  end
@@ -140,31 +148,55 @@ module PgSqlTriggers
140
148
  }
141
149
  end
142
150
 
143
- def create_trigger(form, _actor: nil)
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
- # Determine if we're in a Rails app context or standalone gem
147
- base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
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
- # Register in TriggerRegistry
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: function_content,
222
+ function_body: form.function_body,
187
223
  checksum: calculate_checksum(definition)
188
224
  }
189
225
 
190
- # Only include condition if the column exists and value is present
191
- attributes[:condition] = form.condition.presence if TriggerRegistry.column_names.include?("condition")
226
+ add_conditional_attributes(attributes, form)
227
+ attributes
228
+ end
192
229
 
193
- registry = TriggerRegistry.create!(attributes)
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
- rescue StandardError => e
208
- Rails.logger.error("Trigger generation failed: #{e.message}") if defined?(Rails)
209
- Rails.logger.error(e.backtrace.join("\n")) if defined?(Rails)
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: e.message
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
- Digest::SHA256.hexdigest(definition.to_json)
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgSqlTriggers
4
- class Migration < ActiveRecord::Migration[6.0]
4
+ class Migration < ActiveRecord::Migration[6.1]
5
5
  # Base class for trigger migrations
6
6
  # Similar to ActiveRecord::Migration but for trigger-specific migrations
7
7