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.
- checksums.yaml +4 -4
- data/.erb_lint.yml +0 -0
- data/.rspec +0 -0
- data/.rubocop.yml +6 -16
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +354 -0
- data/COVERAGE.md +39 -41
- data/LICENSE +0 -0
- data/README.md +44 -26
- data/RELEASE.md +0 -0
- data/Rakefile +5 -0
- data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
- data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
- data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
- data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
- data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
- data/app/models/pg_sql_triggers/application_record.rb +0 -0
- data/app/models/pg_sql_triggers/audit_log.rb +29 -47
- data/app/models/pg_sql_triggers/trigger_registry.rb +137 -74
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -27
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +27 -18
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
- data/config/initializers/pg_sql_triggers.rb +0 -0
- data/config/routes.rb +0 -14
- data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
- data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
- data/db/migrate/20260228162233_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
- data/docs/README.md +3 -0
- data/docs/api-reference.md +176 -152
- data/docs/audit-trail.md +1 -1
- data/docs/configuration.md +196 -3
- data/docs/getting-started.md +31 -16
- data/docs/kill-switch.md +0 -0
- data/docs/permissions.md +6 -9
- data/docs/troubleshooting.md +0 -0
- data/docs/ui-guide.md +0 -0
- data/docs/usage-guide.md +112 -67
- data/docs/web-ui.md +3 -103
- data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/README +0 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
- data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
- data/lib/pg_sql_triggers/alerting.rb +77 -0
- data/lib/pg_sql_triggers/database_introspection.rb +0 -0
- data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +26 -13
- data/lib/pg_sql_triggers/drift/detector.rb +59 -38
- data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
- data/lib/pg_sql_triggers/drift.rb +5 -0
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +68 -20
- data/lib/pg_sql_triggers/dsl.rb +0 -0
- data/lib/pg_sql_triggers/engine.rb +49 -0
- data/lib/pg_sql_triggers/errors.rb +0 -0
- data/lib/pg_sql_triggers/events_checksum.rb +114 -0
- data/lib/pg_sql_triggers/migration.rb +5 -6
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +85 -82
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +34 -12
- data/lib/pg_sql_triggers/migrator.rb +137 -94
- data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
- data/lib/pg_sql_triggers/permissions.rb +1 -0
- data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
- data/lib/pg_sql_triggers/registry/manager.rb +60 -21
- data/lib/pg_sql_triggers/registry/validator.rb +287 -6
- data/lib/pg_sql_triggers/registry.rb +0 -0
- data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
- data/lib/pg_sql_triggers/sql/kill_switch.rb +154 -275
- data/lib/pg_sql_triggers/sql.rb +0 -6
- data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
- data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
- data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
- data/lib/pg_sql_triggers/testing.rb +0 -0
- data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +21 -1
- data/lib/tasks/trigger_migrations.rake +235 -152
- data/rakelib/pg_sql_triggers_environment.rake +9 -0
- data/scripts/generate_coverage_report.rb +4 -1
- data/sig/pg_sql_triggers.rbs +0 -0
- metadata +68 -22
- data/Goal.md +0 -742
- data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
- data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
- data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
- data/lib/generators/trigger/migration_generator.rb +0 -60
- data/lib/pg_sql_triggers/generator/form.rb +0 -80
- data/lib/pg_sql_triggers/generator/service.rb +0 -339
- data/lib/pg_sql_triggers/generator.rb +0 -8
- data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
- 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
|
|
55
|
-
|
|
55
|
+
self.version = 1
|
|
56
|
+
self.enabled = true
|
|
57
|
+
timing :before
|
|
56
58
|
end
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
###
|
|
61
|
+
### Generate and Run Migration
|
|
60
62
|
|
|
61
63
|
```bash
|
|
62
|
-
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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?, :
|
|
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
|
|
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
|
|
103
|
-
|
|
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, :
|
|
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
|
|
File without changes
|
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
26
|
+
paginate_audit_logs
|
|
27
|
+
load_filter_options
|
|
17
28
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
respond_to do |format|
|
|
30
|
+
format.html
|
|
31
|
+
format.csv { send_csv_response(scope) }
|
|
21
32
|
end
|
|
33
|
+
end
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
@audit_logs = @audit_logs.for_environment(params[:environment]) if params[:environment].present?
|
|
35
|
+
private
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
@
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
29
|
-
|
|
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
|