pg_sql_triggers 1.3.0 → 1.5.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +0 -0
  3. data/.rspec +0 -0
  4. data/.rubocop.yml +6 -16
  5. data/AGENTS.md +8 -0
  6. data/CHANGELOG.md +354 -0
  7. data/COVERAGE.md +39 -41
  8. data/LICENSE +0 -0
  9. data/README.md +44 -26
  10. data/RELEASE.md +0 -0
  11. data/Rakefile +5 -0
  12. data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
  13. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
  14. data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
  15. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
  16. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
  17. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
  18. data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
  19. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
  20. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
  21. data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
  22. data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
  23. data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
  24. data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
  25. data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
  26. data/app/models/pg_sql_triggers/application_record.rb +0 -0
  27. data/app/models/pg_sql_triggers/audit_log.rb +29 -47
  28. data/app/models/pg_sql_triggers/trigger_registry.rb +137 -74
  29. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
  30. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
  31. data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -27
  32. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
  33. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
  34. data/app/views/pg_sql_triggers/tables/index.html.erb +27 -18
  35. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
  36. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
  37. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
  38. data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
  39. data/config/initializers/pg_sql_triggers.rb +0 -0
  40. data/config/routes.rb +0 -14
  41. data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
  42. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
  43. data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
  44. data/db/migrate/20260228162233_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  45. data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
  46. data/docs/README.md +3 -0
  47. data/docs/api-reference.md +176 -152
  48. data/docs/audit-trail.md +1 -1
  49. data/docs/configuration.md +196 -3
  50. data/docs/getting-started.md +31 -16
  51. data/docs/kill-switch.md +0 -0
  52. data/docs/permissions.md +6 -9
  53. data/docs/troubleshooting.md +0 -0
  54. data/docs/ui-guide.md +0 -0
  55. data/docs/usage-guide.md +112 -67
  56. data/docs/web-ui.md +3 -103
  57. data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
  58. data/lib/generators/pg_sql_triggers/templates/README +0 -0
  59. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
  60. data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
  61. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  62. data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
  63. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  64. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  65. data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
  66. data/lib/pg_sql_triggers/alerting.rb +77 -0
  67. data/lib/pg_sql_triggers/database_introspection.rb +0 -0
  68. data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
  69. data/lib/pg_sql_triggers/drift/db_queries.rb +26 -13
  70. data/lib/pg_sql_triggers/drift/detector.rb +59 -38
  71. data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
  72. data/lib/pg_sql_triggers/drift.rb +5 -0
  73. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +68 -20
  74. data/lib/pg_sql_triggers/dsl.rb +0 -0
  75. data/lib/pg_sql_triggers/engine.rb +49 -0
  76. data/lib/pg_sql_triggers/errors.rb +0 -0
  77. data/lib/pg_sql_triggers/events_checksum.rb +114 -0
  78. data/lib/pg_sql_triggers/migration.rb +5 -6
  79. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +85 -82
  80. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
  81. data/lib/pg_sql_triggers/migrator/safety_validator.rb +34 -12
  82. data/lib/pg_sql_triggers/migrator.rb +137 -94
  83. data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
  84. data/lib/pg_sql_triggers/permissions.rb +1 -0
  85. data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
  86. data/lib/pg_sql_triggers/registry/manager.rb +60 -21
  87. data/lib/pg_sql_triggers/registry/validator.rb +287 -6
  88. data/lib/pg_sql_triggers/registry.rb +0 -0
  89. data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
  90. data/lib/pg_sql_triggers/sql/kill_switch.rb +154 -275
  91. data/lib/pg_sql_triggers/sql.rb +0 -6
  92. data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
  93. data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
  94. data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
  95. data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
  96. data/lib/pg_sql_triggers/testing.rb +0 -0
  97. data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
  98. data/lib/pg_sql_triggers/version.rb +1 -1
  99. data/lib/pg_sql_triggers.rb +21 -1
  100. data/lib/tasks/trigger_migrations.rake +235 -152
  101. data/rakelib/pg_sql_triggers_environment.rake +9 -0
  102. data/scripts/generate_coverage_report.rb +4 -1
  103. data/sig/pg_sql_triggers.rbs +0 -0
  104. metadata +68 -22
  105. data/Goal.md +0 -742
  106. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  107. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  108. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  109. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  110. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  111. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  112. data/lib/generators/trigger/migration_generator.rb +0 -60
  113. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  114. data/lib/pg_sql_triggers/generator/service.rb +0 -339
  115. data/lib/pg_sql_triggers/generator.rb +0 -8
  116. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  117. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
data/README.md CHANGED
@@ -42,6 +42,8 @@ rails generate pg_sql_triggers:install
42
42
  rails db:migrate
43
43
  ```
44
44
 
45
+ Schema migrations bundled with the gem are listed in [Getting Started — Gem schema migrations](docs/getting-started.md#gem-schema-migrations) (ordered `db/migrate/*.rb` filenames).
46
+
45
47
  ### Define a Trigger
46
48
 
47
49
  ```ruby
@@ -50,16 +52,18 @@ PgSqlTriggers::DSL.pg_sql_trigger "users_email_validation" do
50
52
  table :users
51
53
  on :insert, :update
52
54
  function :validate_user_email
53
- version 1
54
- enabled false
55
- when_env :production
55
+ self.version = 1
56
+ self.enabled = true
57
+ timing :before
56
58
  end
57
59
  ```
58
60
 
59
- ### Create and Run Migration
61
+ ### Generate and Run Migration
60
62
 
61
63
  ```bash
62
- rails generate trigger:migration add_email_validation
64
+ # Generate a DSL stub + migration in one command
65
+ rails generate pg_sql_triggers:trigger users_email_validation users insert update --timing before --function validate_user_email
66
+
63
67
  rake trigger:migrate
64
68
  ```
65
69
 
@@ -83,39 +87,62 @@ Comprehensive documentation is available in the [docs](docs/) directory:
83
87
  ## Key Features
84
88
 
85
89
  ### Trigger DSL
86
- Define triggers using a Rails-native Ruby DSL with versioning and environment control.
90
+ Define triggers using a Rails-native Ruby DSL with versioning, row/statement-level granularity, and timing control.
91
+
92
+ ### CLI Generator
93
+ Scaffold a DSL stub and migration in one command:
94
+ ```bash
95
+ rails generate pg_sql_triggers:trigger TRIGGER_NAME TABLE_NAME [EVENTS...] [--timing before|after] [--function fn_name]
96
+ ```
97
+ Files land in `app/triggers/` and `db/triggers/` for code review like any other source change.
87
98
 
88
99
  ### Migration System
89
100
  Manage trigger functions and definitions with a migration system similar to Rails schema migrations.
90
101
 
102
+ ### schema.rb, structure.sql, and trigger snapshots
103
+
104
+ `db:schema:dump` does not capture PostgreSQL triggers. This gem addresses that in three ways:
105
+
106
+ 1. **Comments in `schema.rb`** — When using the default Ruby schema format, `rails db:schema:dump` appends a short note listing managed triggers and pointing to `trigger:migrate` / `trigger:load`. Disable with `PgSqlTriggers.append_trigger_notes_to_schema_dump = false`.
107
+ 2. **`db/trigger_structure.sql`** — Run `rails trigger:dump` to write `CREATE FUNCTION` / `CREATE TRIGGER` statements for registered triggers (or all non-internal triggers in `public` if the registry table is absent). Apply on a fresh DB with `rails trigger:load` (runs arbitrary SQL; kill switch applies in protected environments). Override the path with `FILE=...` or `TRIGGER_STRUCTURE_SQL=...`, or set `PgSqlTriggers.trigger_structure_sql_path`.
108
+ 3. **`db:schema:load`** — After loading `schema.rb`, `trigger:migrate` runs automatically so pending trigger migrations apply. Opt out with `SKIP_TRIGGER_MIGRATE_AFTER_SCHEMA_LOAD=1` or `PgSqlTriggers.migrate_triggers_after_schema_load = false`.
109
+
110
+ For a single SQL artifact that includes tables and triggers, set `config.active_record.schema_format = :sql` and use Rails’ `structure.sql` workflow; keep `db/triggers` migrations as the source of truth and refresh `db/trigger_structure.sql` when you want a portable trigger-only snapshot.
111
+
91
112
  ### Drift Detection
92
- Automatically detect when database triggers drift from your DSL definitions.
113
+ Automatically detect when database triggers drift from your DSL definitions. N+1-free bulk detection across all triggers.
114
+
115
+ ### Drift Alerting
116
+ Configure `PgSqlTriggers.drift_notifier` to push alerts to Slack, PagerDuty, email, or any external system when drift detection finds drifted, dropped, or unknown triggers. Schedule `rake trigger:check_drift` (with optional `FAIL_ON_DRIFT=1`) for CI or cron-based monitoring. An `ActiveSupport::Notifications` event `pg_sql_triggers.drift_check` is emitted on every run for APM instrumentation.
117
+
118
+ ### Advanced PostgreSQL Trigger Features
119
+ - **Column-level `UPDATE OF` triggers** — `on_update_of(:email, :status)` fires only when specific columns change, a common audit/performance optimisation.
120
+ - **Constraint triggers with deferral** — `constraint_trigger!` plus `deferrable` / `initially` options emit `CREATE CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED` for referential-integrity patterns that need to fire at transaction commit.
121
+ - **Ordering hints** — `depends_on "other_trigger"` captures intended firing order among same-table triggers. PostgreSQL fires same-kind triggers alphabetically; `rake trigger:validate_order` (and `Registry.validate!`) verifies declared dependencies, flags cycles, and enforces name-ordering so declared order matches execution order.
93
122
 
94
123
  ### Production Kill Switch
95
124
  Multi-layered safety mechanism preventing accidental destructive operations in production environments.
96
125
 
97
126
  ### Web Dashboard
98
- Visual interface for managing triggers, running migrations, and executing SQL capsules. Includes:
127
+ Visual interface for managing triggers and running migrations. Includes:
99
128
  - **Quick Actions**: Enable/disable, drop, and re-execute triggers from dashboard
100
129
  - **Last Applied Tracking**: See when triggers were last applied with human-readable timestamps
101
130
  - **Breadcrumb Navigation**: Easy navigation between dashboard, tables, and triggers
102
131
  - **Permission-Aware UI**: Buttons show/hide based on user role
132
+ - **Search, Filter, and Pagination**: Filter triggers by table, drift state, or source; full-text search on name/table; offset/limit pagination (`?table=users&state=drifted&source=dsl&q=email&trigger_page=2&trigger_per_page=20`)
103
133
 
104
134
  ### Audit Logging
105
135
  Comprehensive audit trail for all trigger operations:
106
136
  - Track who performed each operation (actor tracking)
107
- - Before and after state capture
137
+ - Before and after state capture (including function body)
108
138
  - Success/failure logging with error messages
109
139
  - Reason tracking for drop and re-execute operations
110
140
 
111
- ### SQL Capsules
112
- Emergency SQL execution feature for critical operations with Admin permission requirements, kill switch protection, and comprehensive logging.
113
-
114
141
  ### Drop & Re-Execute Flow
115
142
  Operational controls for trigger lifecycle management with drop and re-execute capabilities, drift comparison, and required reason logging.
116
143
 
117
144
  ### Permissions
118
- Three-tier permission system (Viewer, Operator, Admin) with customizable authorization.
145
+ Three-tier permission system (Viewer, Operator, Admin) with customizable authorization. A startup warning is emitted in production when no `permission_checker` is configured.
119
146
 
120
147
  ## Console API
121
148
 
@@ -142,15 +169,6 @@ PgSqlTriggers::Registry.disable("users_email_validation", actor: current_user, c
142
169
  # Drop and re-execute triggers
143
170
  PgSqlTriggers::Registry.drop("old_trigger", actor: current_user, reason: "No longer needed", confirmation: "EXECUTE TRIGGER_DROP")
144
171
  PgSqlTriggers::Registry.re_execute("drifted_trigger", actor: current_user, reason: "Fix drift", confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
145
-
146
- # Execute SQL capsules
147
- capsule = PgSqlTriggers::SQL::Capsule.new(
148
- name: "emergency_fix",
149
- environment: "production",
150
- purpose: "Fix critical data issue",
151
- sql: "UPDATE users SET status = 'active' WHERE id = 123"
152
- )
153
- PgSqlTriggers::SQL::Executor.execute(capsule, actor: current_user, confirmation: "EXECUTE SQL")
154
172
  ```
155
173
 
156
174
  See the [API Reference](docs/api-reference.md) for complete documentation of all console APIs.
@@ -164,7 +182,7 @@ For working examples and complete demonstrations, check out the [example reposit
164
182
  - **Rails-native**: Works seamlessly with Rails conventions
165
183
  - **Explicit over magic**: No automatic execution
166
184
  - **Safe by default**: Requires explicit confirmation for destructive actions
167
- - **Power with guardrails**: Emergency SQL escape hatches with safety checks
185
+ - **Code review first**: Generator produces files into working tree; no server-side file writes
168
186
 
169
187
  ## Development
170
188
 
@@ -174,9 +192,9 @@ To install this gem locally, run `bundle exec rake install`. To release a new ve
174
192
 
175
193
  ## Test Coverage
176
194
 
177
- See [COVERAGE.md](COVERAGE.md) for detailed coverage information.
178
-
179
- **Total Coverage: 84.97%**
195
+ See [COVERAGE.md](COVERAGE.md) for detailed coverage information. The bundled report was
196
+ generated against an earlier tree and will be regenerated as part of the next release cycle
197
+ (run `bundle exec rspec` with SimpleCov, then `ruby scripts/generate_coverage_report.rb`).
180
198
 
181
199
  ## Contributing
182
200
 
data/RELEASE.md CHANGED
File without changes
data/Rakefile CHANGED
@@ -6,6 +6,11 @@
6
6
  # - rake release: Build, tag, push to git, and publish to RubyGems.org
7
7
  require "bundler/gem_tasks"
8
8
 
9
+ # Minimal Rails + engine task load so `bundle exec rake trigger:*` works from this repo
10
+ # (host apps load these via Rails::Engine#rake_tasks instead).
11
+ Dir[File.expand_path("rakelib/**/*.rake", __dir__)].each { |f| load f }
12
+ load File.expand_path("lib/tasks/trigger_migrations.rake", __dir__)
13
+
9
14
  # RSpec tasks:
10
15
  # - rake spec: Run the test suite
11
16
  require "rspec/core/rake_task"
@@ -7,7 +7,7 @@ module PgSqlTriggers
7
7
  included do
8
8
  # Helper methods available in views
9
9
  helper_method :current_actor, :can_view_triggers?, :can_enable_disable_triggers?,
10
- :can_drop_triggers?, :can_execute_sql?, :can_generate_triggers?, :can_apply_triggers?
10
+ :can_drop_triggers?, :can_execute_sql_operations?, :can_generate_triggers?, :can_apply_triggers?
11
11
  end
12
12
 
13
13
  # Returns the current actor (user) performing the action.
@@ -67,7 +67,7 @@ module PgSqlTriggers
67
67
  redirect_to root_path, alert: "Insufficient permissions. Operator role required."
68
68
  end
69
69
 
70
- # Checks if current actor has admin permissions (drop/re-execute/execute SQL).
70
+ # Checks if current actor has admin permissions (drop/re-execute).
71
71
  #
72
72
  # @raise [ActionController::RedirectError] Redirects if permission denied
73
73
  def check_admin_permission
@@ -99,14 +99,15 @@ module PgSqlTriggers
99
99
  PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger, environment: current_environment)
100
100
  end
101
101
 
102
- # @return [Boolean] true if current actor can execute SQL capsules
103
- def can_execute_sql?
102
+ # @return [Boolean] true if the +:execute_sql+ action is allowed (privileged SQL for host apps;
103
+ # not used by built-in UI)
104
+ def can_execute_sql_operations?
104
105
  PgSqlTriggers::Permissions.can?(current_actor, :execute_sql, environment: current_environment)
105
106
  end
106
107
 
107
108
  # @return [Boolean] true if current actor can generate triggers
108
109
  def can_generate_triggers?
109
- PgSqlTriggers::Permissions.can?(current_actor, :apply_trigger, environment: current_environment)
110
+ PgSqlTriggers::Permissions.can?(current_actor, :generate_trigger, environment: current_environment)
110
111
  end
111
112
 
112
113
  # @return [Boolean] true if current actor can apply triggers
@@ -4,35 +4,62 @@ module PgSqlTriggers
4
4
  class AuditLogsController < ApplicationController
5
5
  before_action :check_viewer_permission
6
6
 
7
+ TEXT_SEARCH_SQL = [
8
+ "trigger_name ILIKE :t",
9
+ "operation ILIKE :t",
10
+ "COALESCE(reason, '') ILIKE :t",
11
+ "COALESCE(error_message, '') ILIKE :t"
12
+ ].join(" OR ").freeze
13
+
14
+ CSV_HEADERS = [
15
+ "ID", "Trigger Name", "Operation", "Status", "Environment",
16
+ "Actor Type", "Actor ID", "Reason", "Error Message",
17
+ "Created At"
18
+ ].freeze
19
+
7
20
  # GET /audit_logs
8
21
  # Display audit log entries with filtering and sorting
9
22
  def index
10
- @audit_logs = PgSqlTriggers::AuditLog.all
11
-
12
- # Filter by trigger name
13
- @audit_logs = @audit_logs.for_trigger(params[:trigger_name]) if params[:trigger_name].present?
23
+ scope = apply_filters(PgSqlTriggers::AuditLog.all)
24
+ @audit_logs = scope.order(created_at: sort_direction)
14
25
 
15
- # Filter by operation
16
- @audit_logs = @audit_logs.for_operation(params[:operation]) if params[:operation].present?
26
+ paginate_audit_logs
27
+ load_filter_options
17
28
 
18
- # Filter by status
19
- if params[:status].present? && %w[success failure].include?(params[:status])
20
- @audit_logs = @audit_logs.where(status: params[:status])
29
+ respond_to do |format|
30
+ format.html
31
+ format.csv { send_csv_response(scope) }
21
32
  end
33
+ end
22
34
 
23
- # Filter by environment
24
- @audit_logs = @audit_logs.for_environment(params[:environment]) if params[:environment].present?
35
+ private
25
36
 
26
- # Filter by actor (search in JSONB field)
27
- @audit_logs = @audit_logs.where("actor->>'id' = ?", params[:actor_id]) if params[:actor_id].present?
37
+ def apply_filters(scope)
38
+ scope = scope.for_trigger(params[:trigger_name]) if params[:trigger_name].present?
39
+ scope = scope.for_operation(params[:operation]) if params[:operation].present?
40
+ scope = scope.where(status: params[:status]) if valid_status?(params[:status])
41
+ scope = scope.for_environment(params[:environment]) if params[:environment].present?
42
+ scope = scope.where("actor->>'id' = ?", params[:actor_id]) if params[:actor_id].present?
43
+ apply_text_search(scope)
44
+ end
28
45
 
29
- # Sort by date (default: most recent first)
30
- sort_direction = params[:sort] == "asc" ? :asc : :desc
31
- @audit_logs = @audit_logs.order(created_at: sort_direction)
46
+ def apply_text_search(scope)
47
+ return scope if params[:q].blank?
32
48
 
33
- # Pagination
34
- @per_page = (params[:per_page] || 50).to_i
35
- @per_page = [@per_page, 200].min # Cap at 200
49
+ term = "%#{ActiveRecord::Base.sanitize_sql_like(params[:q].to_s.strip)}%"
50
+ scope.where(TEXT_SEARCH_SQL, t: term)
51
+ end
52
+
53
+ def valid_status?(status)
54
+ status.present? && %w[success failure].include?(status)
55
+ end
56
+
57
+ def sort_direction
58
+ params[:sort] == "asc" ? :asc : :desc
59
+ end
60
+
61
+ def paginate_audit_logs
62
+ @per_page = [(params[:per_page] || 50).to_i, 200].min
36
63
  @page = (params[:page] || 1).to_i
37
64
  @total_count = @audit_logs.count
38
65
  @total_pages = @total_count.positive? ? (@total_count.to_f / @per_page).ceil : 1
@@ -40,63 +67,53 @@ module PgSqlTriggers
40
67
 
41
68
  offset = (@page - 1) * @per_page
42
69
  @audit_logs = @audit_logs.offset(offset).limit(@per_page)
70
+ end
43
71
 
44
- # Get distinct values for filter dropdowns
72
+ def load_filter_options
45
73
  @available_trigger_names = PgSqlTriggers::AuditLog.distinct.pluck(:trigger_name).compact.sort
46
74
  @available_operations = PgSqlTriggers::AuditLog.distinct.pluck(:operation).compact.sort
47
75
  @available_environments = PgSqlTriggers::AuditLog.distinct.pluck(:environment).compact.sort
48
-
49
- respond_to do |format|
50
- format.html
51
- format.csv do
52
- send_data generate_csv, filename: "audit_logs_#{Time.current.strftime('%Y%m%d_%H%M%S')}.csv",
53
- type: "text/csv", disposition: "attachment"
54
- end
55
- end
56
76
  end
57
77
 
58
- private
78
+ def send_csv_response(scope)
79
+ send_data generate_csv(scope),
80
+ filename: "audit_logs_#{Time.current.strftime('%Y%m%d_%H%M%S')}.csv",
81
+ type: "text/csv",
82
+ disposition: "attachment"
83
+ end
59
84
 
60
- def generate_csv
85
+ def generate_csv(scope)
61
86
  require "csv"
62
87
 
63
- # Get all audit logs (no pagination for CSV)
64
- audit_logs = PgSqlTriggers::AuditLog.all
65
-
66
- # Apply filters
67
- audit_logs = audit_logs.for_trigger(params[:trigger_name]) if params[:trigger_name].present?
68
- audit_logs = audit_logs.for_operation(params[:operation]) if params[:operation].present?
69
- if params[:status].present? && %w[success failure].include?(params[:status])
70
- audit_logs = audit_logs.where(status: params[:status])
71
- end
72
- audit_logs = audit_logs.for_environment(params[:environment]) if params[:environment].present?
73
- audit_logs = audit_logs.where("actor->>'id' = ?", params[:actor_id]) if params[:actor_id].present?
74
-
75
88
  CSV.generate(headers: true) do |csv|
76
- csv << [
77
- "ID", "Trigger Name", "Operation", "Status", "Environment",
78
- "Actor Type", "Actor ID", "Reason", "Error Message",
79
- "Created At"
80
- ]
81
-
82
- audit_logs.order(created_at: :desc).find_each do |log|
83
- actor_type = log.actor.is_a?(Hash) ? log.actor["type"] || log.actor[:type] : nil
84
- actor_id = log.actor.is_a?(Hash) ? log.actor["id"] || log.actor[:id] : nil
85
-
86
- csv << [
87
- log.id,
88
- log.trigger_name || "",
89
- log.operation,
90
- log.status,
91
- log.environment || "",
92
- actor_type || "",
93
- actor_id || "",
94
- log.reason || "",
95
- log.error_message || "",
96
- log.created_at&.iso8601 || ""
97
- ]
89
+ csv << CSV_HEADERS
90
+ scope.order(created_at: :desc).find_each do |log|
91
+ csv << csv_row_for(log)
98
92
  end
99
93
  end
100
94
  end
95
+
96
+ def csv_row_for(log)
97
+ actor_type, actor_id = extract_actor_fields(log.actor)
98
+
99
+ [
100
+ log.id,
101
+ log.trigger_name || "",
102
+ log.operation,
103
+ log.status,
104
+ log.environment || "",
105
+ actor_type || "",
106
+ actor_id || "",
107
+ log.reason || "",
108
+ log.error_message || "",
109
+ log.created_at&.iso8601 || ""
110
+ ]
111
+ end
112
+
113
+ def extract_actor_fields(actor)
114
+ return [nil, nil] unless actor.is_a?(Hash)
115
+
116
+ [actor["type"] || actor[:type], actor["id"] || actor[:id]]
117
+ end
101
118
  end
102
119
  end
@@ -2,49 +2,126 @@
2
2
 
3
3
  module PgSqlTriggers
4
4
  class DashboardController < ApplicationController
5
+ helper DashboardHelper
6
+
5
7
  before_action :check_viewer_permission
6
8
 
9
+ DRIFT_STATE_PARAM_MAP = {
10
+ "in_sync" => PgSqlTriggers::DRIFT_STATE_IN_SYNC,
11
+ "drifted" => PgSqlTriggers::DRIFT_STATE_DRIFTED,
12
+ "disabled" => PgSqlTriggers::DRIFT_STATE_DISABLED,
13
+ "dropped" => PgSqlTriggers::DRIFT_STATE_DROPPED,
14
+ "unknown" => PgSqlTriggers::DRIFT_STATE_UNKNOWN,
15
+ "manual_override" => PgSqlTriggers::DRIFT_STATE_MANUAL_OVERRIDE
16
+ }.freeze
17
+
18
+ SOURCE_OPTIONS = %w[dsl generated manual_sql].freeze
19
+
7
20
  def index
8
- # Sort by installed_at descending (most recent first), fallback to created_at
9
- @triggers = PgSqlTriggers::TriggerRegistry.order(
21
+ load_filters
22
+ load_triggers
23
+ load_migration_status
24
+ end
25
+
26
+ private
27
+
28
+ def load_filters
29
+ @filter_table = params[:table].presence
30
+ @filter_state = params[:state].presence
31
+ @filter_source = params[:source].presence
32
+ @filter_query = params[:q].presence
33
+ end
34
+
35
+ def load_triggers
36
+ ordered = PgSqlTriggers::TriggerRegistry.order(
10
37
  Arel.sql("COALESCE(installed_at, created_at) DESC")
11
38
  )
39
+ drift_results = PgSqlTriggers::Drift::Detector.detect_all
40
+ @stats = build_stats(ordered, drift_results)
41
+
42
+ filtered = apply_trigger_filters(ordered, drift_results)
43
+ @trigger_list_total = filtered.count
44
+
45
+ paginate_triggers(filtered)
46
+ @filter_table_names = PgSqlTriggers::TriggerRegistry.distinct.order(:table_name).pluck(:table_name)
47
+ end
48
+
49
+ def paginate_triggers(filtered)
50
+ @trigger_per_page = [(params[:trigger_per_page] || 20).to_i, 100].min
51
+ @trigger_page = (params[:trigger_page] || 1).to_i
52
+ @trigger_total_pages = @trigger_list_total.positive? ? (@trigger_list_total.to_f / @trigger_per_page).ceil : 1
53
+ @trigger_page = @trigger_page.clamp(1, [@trigger_total_pages, 1].max)
54
+
55
+ offset = (@trigger_page - 1) * @trigger_per_page
56
+ @triggers = filtered.offset(offset).limit(@trigger_per_page)
57
+ end
58
+
59
+ def load_migration_status
60
+ all_migrations = PgSqlTriggers::Migrator.status
61
+ @pending_migrations = PgSqlTriggers::Migrator.pending_migrations
62
+ @current_migration_version = PgSqlTriggers::Migrator.current_version
63
+
64
+ paginate_migrations(all_migrations)
65
+ rescue StandardError => e
66
+ Rails.logger.error("Failed to fetch migration status: #{e.message}")
67
+ reset_migration_defaults
68
+ end
69
+
70
+ def paginate_migrations(all_migrations)
71
+ @per_page = [(params[:per_page] || 20).to_i, 100].min
72
+ @page = (params[:page] || 1).to_i
73
+ @total_migrations = all_migrations.count
74
+ @total_pages = @total_migrations.positive? ? (@total_migrations.to_f / @per_page).ceil : 1
75
+ @page = @page.clamp(1, @total_pages)
76
+
77
+ migration_offset = (@page - 1) * @per_page
78
+ @migration_status = all_migrations.slice(migration_offset, @per_page) || []
79
+ end
12
80
 
13
- # Get drift summary
14
- drift_summary = PgSqlTriggers::Drift::Reporter.summary
15
- @stats = {
16
- total: @triggers.count,
17
- enabled: @triggers.enabled.count,
18
- disabled: @triggers.disabled.count,
19
- drifted: drift_summary[:drifted]
81
+ def reset_migration_defaults
82
+ @migration_status = []
83
+ @pending_migrations = []
84
+ @current_migration_version = 0
85
+ @total_migrations = 0
86
+ @total_pages = 1
87
+ @page = 1
88
+ @per_page = 20
89
+ end
90
+
91
+ def build_stats(ordered_scope, drift_results)
92
+ {
93
+ total: ordered_scope.count,
94
+ enabled: ordered_scope.enabled.count,
95
+ disabled: ordered_scope.disabled.count,
96
+ drifted: drift_results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED }
20
97
  }
98
+ end
99
+
100
+ def apply_trigger_filters(relation, drift_results)
101
+ scoped = relation
102
+ scoped = scoped.for_table(@filter_table) if @filter_table.present?
21
103
 
22
- # Migration status with pagination
23
- begin
24
- all_migrations = PgSqlTriggers::Migrator.status
25
- @pending_migrations = PgSqlTriggers::Migrator.pending_migrations
26
- @current_migration_version = PgSqlTriggers::Migrator.current_version
27
-
28
- # Pagination
29
- @per_page = (params[:per_page] || 20).to_i
30
- @per_page = [@per_page, 100].min # Cap at 100
31
- @page = (params[:page] || 1).to_i
32
- @total_migrations = all_migrations.count
33
- @total_pages = @total_migrations.positive? ? (@total_migrations.to_f / @per_page).ceil : 1
34
- @page = @page.clamp(1, @total_pages) # Ensure page is within valid range
35
-
36
- offset = (@page - 1) * @per_page
37
- @migration_status = all_migrations.slice(offset, @per_page) || []
38
- rescue StandardError => e
39
- Rails.logger.error("Failed to fetch migration status: #{e.message}")
40
- @migration_status = []
41
- @pending_migrations = []
42
- @current_migration_version = 0
43
- @total_migrations = 0
44
- @total_pages = 1
45
- @page = 1
46
- @per_page = 20
104
+ scoped = scoped.by_source(@filter_source) if @filter_source.present? && SOURCE_OPTIONS.include?(@filter_source)
105
+
106
+ scoped = scoped.matching_search(@filter_query) if @filter_query.present?
107
+
108
+ if @filter_state.present? && DRIFT_STATE_PARAM_MAP.key?(@filter_state)
109
+ scoped = filter_by_drift_state(scoped, drift_results, DRIFT_STATE_PARAM_MAP[@filter_state])
110
+ end
111
+
112
+ scoped
113
+ end
114
+
115
+ def filter_by_drift_state(relation, drift_results, drift_constant)
116
+ names = drift_results.each_with_object([]) do |result, acc|
117
+ next unless result[:state] == drift_constant
118
+
119
+ entry = result[:registry_entry]
120
+ acc << entry.trigger_name if entry
47
121
  end
122
+ return relation.none if names.empty?
123
+
124
+ relation.where(trigger_name: names)
48
125
  end
49
126
  end
50
127
  end
@@ -82,13 +82,13 @@ module PgSqlTriggers
82
82
  return
83
83
  end
84
84
 
85
- if target_version
86
- redo_target_migration(target_version, current_version)
87
- else
88
- PgSqlTriggers::Migrator.run_down
89
- PgSqlTriggers::Migrator.run_up
90
- flash[:success] = "Last migration redone successfully."
91
- end
85
+ flash[:success] = if target_version
86
+ redo_target_migration(target_version, current_version)
87
+ else
88
+ PgSqlTriggers::Migrator.run_down
89
+ PgSqlTriggers::Migrator.run_up
90
+ "Last migration redone successfully."
91
+ end
92
92
  redirect_to root_path
93
93
  rescue PgSqlTriggers::KillSwitchError => e
94
94
  flash[:error] = e.message
@@ -107,10 +107,11 @@ module PgSqlTriggers
107
107
  redirect_to root_path, alert: "Insufficient permissions. Operator role required."
108
108
  end
109
109
 
110
+ # Returns the flash message that should be set by the caller after a successful redo.
110
111
  def redo_target_migration(target_version, current_version)
111
- # For redo, we need to rollback the specific migration and re-apply it
112
- # If target_version is the current version, rollback the last migration
113
- # Otherwise, rollback to one version before target, then run up to target
112
+ # For redo, we need to rollback the specific migration and re-apply it.
113
+ # If target_version is the current version, rollback the last migration.
114
+ # Otherwise, rollback to one version before target, then run up to target.
114
115
  if current_version == target_version
115
116
  # Rollback the last migration (which is the target)
116
117
  PgSqlTriggers::Migrator.run_down
@@ -119,14 +120,12 @@ module PgSqlTriggers
119
120
  else
120
121
  # Target version is not applied yet, just run it up
121
122
  PgSqlTriggers::Migrator.run_up(target_version)
122
- flash[:success] = "Migration #{target_version} redone successfully."
123
- redirect_to root_path
124
- return
123
+ return "Migration #{target_version} redone successfully."
125
124
  end
126
125
 
127
126
  # Now run up to the target version
128
127
  PgSqlTriggers::Migrator.run_up(target_version)
129
- flash.now[:success] = "Migration #{target_version} redone successfully."
128
+ "Migration #{target_version} redone successfully."
130
129
  end
131
130
 
132
131
  def rollback_to_before_target(target_version)
@@ -23,6 +23,14 @@ module PgSqlTriggers
23
23
  all_tables
24
24
  end
25
25
 
26
+ @search_query = params[:q].to_s.strip
27
+ if @search_query.present?
28
+ needle = @search_query.downcase
29
+ filtered_tables = filtered_tables.select do |row|
30
+ row[:table_name].to_s.downcase.include?(needle)
31
+ end
32
+ end
33
+
26
34
  @total_tables = filtered_tables.count
27
35
 
28
36
  # Pagination
@@ -12,6 +12,7 @@ module PgSqlTriggers
12
12
  def show
13
13
  # Load trigger details and drift information
14
14
  @drift_info = calculate_drift_info
15
+ @dependency_related = PgSqlTriggers::Registry::Validator.related_triggers_for_show(@trigger)
15
16
  end
16
17
 
17
18
  def enable
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # URL helpers for dashboard list filters and dual pagination (triggers vs migrations).
5
+ module DashboardHelper
6
+ # Params to preserve when linking within the dashboard (filters + both paginations).
7
+ DASHBOARD_PARAM_KEYS = %i[
8
+ table state source q
9
+ trigger_page trigger_per_page
10
+ page per_page
11
+ ].freeze
12
+
13
+ def dashboard_list_params(extra = {})
14
+ keys = DashboardHelper::DASHBOARD_PARAM_KEYS
15
+ base = params.permit(*keys).to_h.symbolize_keys
16
+ base.merge(extra.symbolize_keys).compact_blank
17
+ end
18
+ end
19
+ end
@@ -25,8 +25,9 @@ module PgSqlTriggers
25
25
  can?(:drop_trigger)
26
26
  end
27
27
 
28
- # Check if the current actor can execute SQL capsules
29
- def can_execute_sql?
28
+ # Check if the current actor has the +:execute_sql+ permission (admin-level SQL;
29
+ # host apps may use this in custom tooling — not used by built-in UI)
30
+ def can_execute_sql_operations?
30
31
  can?(:execute_sql)
31
32
  end
32
33
 
File without changes