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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/COVERAGE.md +58 -0
  4. data/Goal.md +180 -138
  5. data/README.md +8 -2
  6. data/RELEASE.md +1 -1
  7. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
  8. data/app/controllers/pg_sql_triggers/generator_controller.rb +67 -5
  9. data/app/models/pg_sql_triggers/trigger_registry.rb +73 -10
  10. data/app/views/pg_sql_triggers/generator/new.html.erb +18 -0
  11. data/app/views/pg_sql_triggers/generator/preview.html.erb +233 -13
  12. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +32 -0
  13. data/config/initializers/pg_sql_triggers.rb +69 -0
  14. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +2 -0
  15. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
  16. data/docs/README.md +2 -2
  17. data/docs/api-reference.md +22 -4
  18. data/docs/getting-started.md +1 -1
  19. data/docs/kill-switch.md +3 -3
  20. data/docs/usage-guide.md +73 -0
  21. data/docs/web-ui.md +14 -0
  22. data/lib/generators/pg_sql_triggers/templates/README +1 -1
  23. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +2 -0
  24. data/lib/generators/pg_sql_triggers/templates/initializer.rb +8 -0
  25. data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
  26. data/lib/pg_sql_triggers/drift/detector.rb +187 -0
  27. data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
  28. data/lib/pg_sql_triggers/drift.rb +14 -11
  29. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
  30. data/lib/pg_sql_triggers/generator/form.rb +3 -1
  31. data/lib/pg_sql_triggers/generator/service.rb +81 -25
  32. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
  33. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
  34. data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
  35. data/lib/pg_sql_triggers/migrator.rb +58 -0
  36. data/lib/pg_sql_triggers/registry/manager.rb +96 -9
  37. data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
  38. data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
  39. data/lib/pg_sql_triggers/version.rb +1 -1
  40. data/lib/pg_sql_triggers.rb +12 -0
  41. data/scripts/generate_coverage_report.rb +129 -0
  42. metadata +19 -12
@@ -8,6 +8,7 @@ module PgSqlTriggers
8
8
  end
9
9
 
10
10
  # Test ONLY the function, not the trigger
11
+ # rubocop:disable Lint/UnusedMethodArgument
11
12
  def test_function_only(test_context: {})
12
13
  results = {
13
14
  function_created: false,
@@ -16,15 +17,44 @@ module PgSqlTriggers
16
17
  output: []
17
18
  }
18
19
 
20
+ # Check if function_body is present
21
+ if @trigger.function_body.blank?
22
+ results[:success] = false
23
+ results[:errors] << "Function body is missing"
24
+ return results
25
+ end
26
+
27
+ # Extract function name to verify it matches
28
+ function_name_from_body = nil
29
+ if @trigger.function_body.present?
30
+ pattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i
31
+ match = @trigger.function_body.match(pattern)
32
+ function_name_from_body = match[1] if match
33
+ end
34
+
35
+ # If function_body doesn't contain a valid function definition, fail early
36
+ unless function_name_from_body
37
+ results[:success] = false
38
+ results[:errors] << "Function body does not contain a valid CREATE FUNCTION statement"
39
+ return results
40
+ end
41
+
42
+ # rubocop:disable Metrics/BlockLength
19
43
  ActiveRecord::Base.transaction do
20
44
  # Create function in transaction
21
- ActiveRecord::Base.connection.execute(@trigger.function_body)
22
- results[:function_created] = true
23
- results[:output] << "✓ Function created in test transaction"
45
+ begin
46
+ ActiveRecord::Base.connection.execute(@trigger.function_body)
47
+ results[:function_created] = true
48
+ results[:output] << "✓ Function created in test transaction"
49
+ rescue ActiveRecord::StatementInvalid, StandardError => e
50
+ results[:success] = false
51
+ results[:errors] << "Error during function creation: #{e.message}"
52
+ # Don't raise here, let it fall through to ensure block for rollback
53
+ end
24
54
 
25
55
  # Try to invoke function directly (if test context provided)
26
56
  # Note: Empty hash {} is not "present" in Rails, so check if it's not nil
27
- if !test_context.nil? && results[:function_created]
57
+ if results[:function_created]
28
58
  # This would require custom invocation logic
29
59
  # For now, just verify it was created - if function was successfully created,
30
60
  # we can assume it exists and is executable within the transaction
@@ -51,40 +81,50 @@ module PgSqlTriggers
51
81
  end
52
82
 
53
83
  # Verify function exists in database by checking pg_proc
54
- # Since the function was created successfully (function_created is true),
55
- # it exists and is executable
56
- results[:function_executed] = true
57
-
58
84
  # Try to verify via query if function_name is available
59
85
  if function_name.present?
60
- sanitized_name = ActiveRecord::Base.connection.quote_string(function_name)
61
- check_sql = <<~SQL.squish
62
- SELECT COUNT(*) as count
63
- FROM pg_proc p
64
- JOIN pg_namespace n ON p.pronamespace = n.oid
65
- WHERE p.proname = '#{sanitized_name}'
66
- AND n.nspname = 'public'
67
- SQL
68
-
69
86
  begin
87
+ sanitized_name = begin
88
+ ActiveRecord::Base.connection.quote_string(function_name)
89
+ rescue StandardError => e
90
+ # If quote_string fails, use the function name as-is (less safe but allows test to continue)
91
+ results[:errors] << "Error during function name sanitization: #{e.message}"
92
+ function_name
93
+ end
94
+ check_sql = <<~SQL.squish
95
+ SELECT COUNT(*) as count
96
+ FROM pg_proc p
97
+ JOIN pg_namespace n ON p.pronamespace = n.oid
98
+ WHERE p.proname = '#{sanitized_name}'
99
+ AND n.nspname = 'public'
100
+ SQL
101
+
70
102
  result = ActiveRecord::Base.connection.execute(check_sql).first
71
- results[:output] << if result && result["count"].to_i.positive?
103
+ results[:function_executed] = result && result["count"].to_i.positive?
104
+ results[:output] << if results[:function_executed]
72
105
  "✓ Function exists and is callable"
73
106
  else
74
107
  "✓ Function created (verified via successful creation)"
75
108
  end
76
- rescue StandardError
77
- results[:output] << "✓ Function created (verified via successful creation)"
109
+ rescue ActiveRecord::StatementInvalid, StandardError => e
110
+ results[:function_executed] = false
111
+ results[:success] = false
112
+ results[:errors] << "Error during function verification: #{e.message}"
113
+ results[:output] << "✓ Function created (verification failed)"
78
114
  end
79
115
  else
116
+ # If we can't extract function name, assume it was created successfully
117
+ # since function_created is true
118
+ results[:function_executed] = true
80
119
  results[:output] << "✓ Function created (execution verified via successful creation)"
81
120
  end
82
121
  end
83
122
 
84
- results[:success] = true
85
- rescue ActiveRecord::StatementInvalid => e
123
+ # Set success to true only if no errors occurred and function was created
124
+ results[:success] = results[:errors].empty? && results[:function_created]
125
+ rescue ActiveRecord::StatementInvalid, StandardError => e
86
126
  results[:success] = false
87
- results[:errors] << e.message
127
+ results[:errors] << e.message unless results[:errors].include?(e.message)
88
128
  ensure
89
129
  raise ActiveRecord::Rollback
90
130
  end
@@ -92,6 +132,7 @@ module PgSqlTriggers
92
132
  results[:output] << "\n⚠ Function rolled back (test mode)"
93
133
  results
94
134
  end
135
+ # rubocop:enable Lint/UnusedMethodArgument, Metrics/BlockLength
95
136
 
96
137
  # Check if function already exists in database
97
138
  def function_exists?
@@ -100,7 +141,8 @@ module PgSqlTriggers
100
141
  rescue StandardError
101
142
  {}
102
143
  end
103
- function_name = definition["function_name"]
144
+ function_name = definition["function_name"] || definition["name"] ||
145
+ definition[:function_name] || definition[:name]
104
146
  return false if function_name.blank?
105
147
 
106
148
  sanitized_name = ActiveRecord::Base.connection.quote_string(function_name)
@@ -65,10 +65,33 @@ module PgSqlTriggers
65
65
  {}
66
66
  end
67
67
  function_name = definition["function_name"] || "test_validation_function"
68
+ events = Array(definition["events"] || [])
68
69
  sanitized_table = ActiveRecord::Base.connection.quote_string(@trigger.table_name)
69
70
  sanitized_function = ActiveRecord::Base.connection.quote_string(function_name)
70
71
  sanitized_condition = @trigger.condition
71
72
 
73
+ # Check if condition references OLD values
74
+ condition_uses_old = sanitized_condition.match?(/\bOLD\./i)
75
+
76
+ # Determine which event to use for validation
77
+ # If condition uses OLD, we must use UPDATE or DELETE since INSERT doesn't have OLD
78
+ # If condition doesn't use OLD, we can use INSERT
79
+ if condition_uses_old
80
+ # Condition references OLD, so it can't be used with INSERT
81
+ if events.include?("insert")
82
+ return {
83
+ valid: false,
84
+ error: "WHEN condition cannot reference OLD values for INSERT triggers. " \
85
+ "Use UPDATE or DELETE events, or modify condition to only use NEW values."
86
+ }
87
+ end
88
+ # Use UPDATE for validation if available (it has OLD), otherwise use DELETE
89
+ test_event = events.include?("update") ? "UPDATE" : "DELETE"
90
+ else
91
+ # Condition doesn't reference OLD, so INSERT is fine
92
+ test_event = "INSERT"
93
+ end
94
+
72
95
  # Validate condition by creating a temporary trigger with the condition
73
96
  # This is the only way to validate WHEN conditions since they use NEW/OLD
74
97
  test_function_sql = <<~SQL.squish
@@ -81,7 +104,7 @@ module PgSqlTriggers
81
104
 
82
105
  test_trigger_sql = <<~SQL.squish
83
106
  CREATE TRIGGER test_validation_trigger
84
- BEFORE INSERT ON #{sanitized_table}
107
+ BEFORE #{test_event} ON #{sanitized_table}
85
108
  FOR EACH ROW
86
109
  WHEN (#{sanitized_condition})
87
110
  EXECUTE FUNCTION #{sanitized_function}();
@@ -11,5 +11,5 @@
11
11
  # 3. Run: bundle exec rake release
12
12
  # See RELEASE.md for detailed release instructions
13
13
  module PgSqlTriggers
14
- VERSION = "1.0.1"
14
+ VERSION = "1.1.1"
15
15
  end
@@ -9,6 +9,7 @@ module PgSqlTriggers
9
9
  class DriftError < Error; end
10
10
  class KillSwitchError < Error; end
11
11
  class ValidationError < Error; end
12
+ class UnsafeMigrationError < Error; end
12
13
 
13
14
  # Configuration
14
15
  mattr_accessor :kill_switch_enabled
@@ -35,6 +36,17 @@ module PgSqlTriggers
35
36
  mattr_accessor :excluded_tables
36
37
  self.excluded_tables = []
37
38
 
39
+ mattr_accessor :allow_unsafe_migrations
40
+ self.allow_unsafe_migrations = false
41
+
42
+ # Drift states
43
+ DRIFT_STATE_IN_SYNC = "in_sync"
44
+ DRIFT_STATE_DRIFTED = "drifted"
45
+ DRIFT_STATE_MANUAL_OVERRIDE = "manual_override"
46
+ DRIFT_STATE_DISABLED = "disabled"
47
+ DRIFT_STATE_DROPPED = "dropped"
48
+ DRIFT_STATE_UNKNOWN = "unknown"
49
+
38
50
  def self.configure
39
51
  yield self
40
52
  end
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "pathname"
6
+ require "active_support/core_ext/object/blank"
7
+
8
+ def calculate_file_coverage(lines)
9
+ return 0.0 if lines.blank?
10
+
11
+ relevant_lines = lines.compact
12
+ return 0.0 if relevant_lines.empty?
13
+
14
+ covered = relevant_lines.count { |line| line&.positive? }
15
+ total = relevant_lines.count
16
+
17
+ (covered.to_f / total * 100).round(2)
18
+ end
19
+
20
+ def extract_file_path(full_path)
21
+ # Remove workspace root and get relative path
22
+ workspace_root = Pathname.new(__dir__).parent.realpath
23
+ file_path = Pathname.new(full_path)
24
+
25
+ if file_path.to_s.start_with?(workspace_root.to_s)
26
+ file_path.relative_path_from(workspace_root).to_s
27
+ else
28
+ # Fallback: try to extract relative path
29
+ full_path.sub(%r{.*/(lib|app)/}, '\1/')
30
+ end
31
+ end
32
+
33
+ def parse_coverage_data
34
+ coverage_dir = Pathname.new(__dir__).parent.join("coverage")
35
+ resultset_file = coverage_dir.join(".resultset.json")
36
+ last_run_file = coverage_dir.join(".last_run.json")
37
+
38
+ unless resultset_file.exist?
39
+ puts "Error: Coverage resultset file not found at #{resultset_file}"
40
+ exit 1
41
+ end
42
+
43
+ resultset_data = JSON.parse(File.read(resultset_file))
44
+ last_run_file.exist? ? JSON.parse(File.read(last_run_file)) : {}
45
+
46
+ # Get the first (and usually only) test suite result
47
+ test_suite_key = resultset_data.keys.first
48
+ coverage_data = resultset_data[test_suite_key]["coverage"] || {}
49
+
50
+ file_coverage = {}
51
+ total_lines = 0
52
+ total_covered = 0
53
+
54
+ coverage_data.each do |full_path, file_data|
55
+ next unless file_data["lines"]
56
+
57
+ relative_path = extract_file_path(full_path)
58
+ # Skip files in spec, vendor, etc.
59
+ next if relative_path.include?("/spec/") || relative_path.include?("/vendor/")
60
+
61
+ lines = file_data["lines"]
62
+ coverage_percentage = calculate_file_coverage(lines)
63
+
64
+ # Count relevant lines (non-nil lines)
65
+ relevant_lines = lines.compact
66
+ covered_lines = relevant_lines.count { |line| line&.positive? }
67
+
68
+ file_coverage[relative_path] = {
69
+ percentage: coverage_percentage,
70
+ total_lines: relevant_lines.count,
71
+ covered_lines: covered_lines,
72
+ missed_lines: relevant_lines.count - covered_lines
73
+ }
74
+
75
+ total_lines += relevant_lines.count
76
+ total_covered += covered_lines
77
+ end
78
+
79
+ total_coverage = total_lines.positive? ? (total_covered.to_f / total_lines * 100).round(2) : 0.0
80
+
81
+ {
82
+ file_coverage: file_coverage.sort_by { |_k, v| -v[:percentage] },
83
+ total_coverage: total_coverage,
84
+ total_lines: total_lines,
85
+ total_covered: total_covered
86
+ }
87
+ end
88
+
89
+ def generate_markdown(coverage_info)
90
+ markdown = "# Code Coverage Report\n\n"
91
+ markdown += "**Total Coverage: #{coverage_info[:total_coverage]}%**\n\n"
92
+ markdown += "Covered: #{coverage_info[:total_covered]} / #{coverage_info[:total_lines]} lines\n\n"
93
+ markdown += "---\n\n"
94
+ markdown += "## File Coverage\n\n"
95
+ markdown += "| File | Coverage | Covered Lines | Missed Lines | Total Lines |\n"
96
+ markdown += "|------|----------|---------------|--------------|-------------|\n"
97
+
98
+ coverage_info[:file_coverage].each do |file_path, data|
99
+ status_icon = if data[:percentage] >= 90
100
+ "✅"
101
+ else
102
+ data[:percentage] >= 70 ? "⚠️" : "❌"
103
+ end
104
+ markdown += "| `#{file_path}` | #{data[:percentage]}% #{status_icon} | " \
105
+ "#{data[:covered_lines]} | #{data[:missed_lines]} | #{data[:total_lines]} |\n"
106
+ end
107
+
108
+ markdown += "\n---\n\n"
109
+ markdown += "*Report generated automatically from SimpleCov results*\n"
110
+ markdown += "*To regenerate: Run `bundle exec rspec` and then `ruby scripts/generate_coverage_report.rb`*\n"
111
+
112
+ markdown
113
+ end
114
+
115
+ # Main execution
116
+ begin
117
+ coverage_info = parse_coverage_data
118
+ markdown_content = generate_markdown(coverage_info)
119
+
120
+ output_file = Pathname.new(__dir__).parent.join("COVERAGE.md")
121
+ File.write(output_file, markdown_content)
122
+
123
+ puts "Coverage report generated: #{output_file}"
124
+ puts "Total Coverage: #{coverage_info[:total_coverage]}%"
125
+ rescue StandardError => e
126
+ puts "Error generating coverage report: #{e.message}"
127
+ puts e.backtrace.first(5).join("\n")
128
+ exit 1
129
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_sql_triggers
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
- - samaswin87
8
- autorequire:
7
+ - samaswin
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-12-28 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: pg
@@ -190,6 +189,7 @@ files:
190
189
  - ".rspec"
191
190
  - ".rubocop.yml"
192
191
  - CHANGELOG.md
192
+ - COVERAGE.md
193
193
  - Goal.md
194
194
  - LICENSE
195
195
  - README.md
@@ -212,8 +212,10 @@ files:
212
212
  - app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb
213
213
  - app/views/pg_sql_triggers/tables/index.html.erb
214
214
  - app/views/pg_sql_triggers/tables/show.html.erb
215
+ - config/initializers/pg_sql_triggers.rb
215
216
  - config/routes.rb
216
217
  - db/migrate/20251222000001_create_pg_sql_triggers_tables.rb
218
+ - db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb
217
219
  - docs/README.md
218
220
  - docs/api-reference.md
219
221
  - docs/configuration.md
@@ -236,6 +238,9 @@ files:
236
238
  - lib/pg_sql_triggers.rb
237
239
  - lib/pg_sql_triggers/database_introspection.rb
238
240
  - lib/pg_sql_triggers/drift.rb
241
+ - lib/pg_sql_triggers/drift/db_queries.rb
242
+ - lib/pg_sql_triggers/drift/detector.rb
243
+ - lib/pg_sql_triggers/drift/reporter.rb
239
244
  - lib/pg_sql_triggers/dsl.rb
240
245
  - lib/pg_sql_triggers/dsl/trigger_definition.rb
241
246
  - lib/pg_sql_triggers/engine.rb
@@ -244,6 +249,9 @@ files:
244
249
  - lib/pg_sql_triggers/generator/service.rb
245
250
  - lib/pg_sql_triggers/migration.rb
246
251
  - lib/pg_sql_triggers/migrator.rb
252
+ - lib/pg_sql_triggers/migrator/pre_apply_comparator.rb
253
+ - lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb
254
+ - lib/pg_sql_triggers/migrator/safety_validator.rb
247
255
  - lib/pg_sql_triggers/permissions.rb
248
256
  - lib/pg_sql_triggers/permissions/checker.rb
249
257
  - lib/pg_sql_triggers/registry.rb
@@ -258,17 +266,17 @@ files:
258
266
  - lib/pg_sql_triggers/testing/syntax_validator.rb
259
267
  - lib/pg_sql_triggers/version.rb
260
268
  - lib/tasks/trigger_migrations.rake
269
+ - scripts/generate_coverage_report.rb
261
270
  - sig/pg_sql_triggers.rbs
262
- homepage: https://github.com/samaswin87/pg_sql_triggers
271
+ homepage: https://github.com/samaswin/pg_sql_triggers
263
272
  licenses:
264
273
  - MIT
265
274
  metadata:
266
- homepage_uri: https://github.com/samaswin87/pg_sql_triggers
267
- source_code_uri: https://github.com/samaswin87/pg_sql_triggers
268
- changelog_uri: https://github.com/samaswin87/pg_sql_triggers/blob/main/CHANGELOG.md
269
- github_repo: ssh://github.com/samaswin87/pg_sql_triggers
275
+ homepage_uri: https://github.com/samaswin/pg_sql_triggers
276
+ source_code_uri: https://github.com/samaswin/pg_sql_triggers
277
+ changelog_uri: https://github.com/samaswin/pg_sql_triggers/blob/main/CHANGELOG.md
278
+ github_repo: ssh://github.com/samaswin/pg_sql_triggers
270
279
  rubygems_mfa_required: 'true'
271
- post_install_message:
272
280
  rdoc_options: []
273
281
  require_paths:
274
282
  - lib
@@ -283,8 +291,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
283
291
  - !ruby/object:Gem::Version
284
292
  version: '0'
285
293
  requirements: []
286
- rubygems_version: 3.5.22
287
- signing_key:
294
+ rubygems_version: 4.0.3
288
295
  specification_version: 4
289
296
  summary: A PostgreSQL Trigger Control Plane for Rails
290
297
  test_files: []