pg_sql_triggers 1.1.1 → 1.2.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/.rubocop.yml +15 -0
- data/CHANGELOG.md +56 -0
- data/COVERAGE.md +32 -28
- data/README.md +29 -0
- data/app/controllers/pg_sql_triggers/application_controller.rb +1 -1
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +10 -0
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +62 -10
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +161 -0
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +165 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +123 -0
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +40 -2
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +81 -0
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +85 -0
- data/app/views/pg_sql_triggers/tables/show.html.erb +36 -2
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +129 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +136 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +186 -0
- data/config/routes.rb +9 -0
- data/docs/api-reference.md +252 -4
- data/docs/web-ui.md +82 -17
- data/lib/pg_sql_triggers/registry/manager.rb +28 -13
- data/lib/pg_sql_triggers/registry.rb +41 -0
- data/lib/pg_sql_triggers/sql/capsule.rb +79 -0
- data/lib/pg_sql_triggers/sql/executor.rb +200 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- metadata +10 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<% form_id = "trigger-re-execute-#{trigger.id}-form" %>
|
|
2
|
+
|
|
3
|
+
<%= form_with url: re_execute_trigger_path(trigger), method: :post, local: true, id: form_id, style: "margin: 0; display: inline-block;" do |f| %>
|
|
4
|
+
<%= f.hidden_field :redirect_to, value: params[:redirect_to] || trigger_path(trigger) %>
|
|
5
|
+
|
|
6
|
+
<button type="button" onclick="showReExecuteModal('<%= form_id %>')"
|
|
7
|
+
style="padding: 0.75rem 1.5rem; background: #ffc107; color: #000; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
|
|
8
|
+
Re-Execute Trigger
|
|
9
|
+
</button>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<!-- Re-Execute Modal -->
|
|
13
|
+
<div id="<%= form_id %>-modal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
|
|
14
|
+
<div style="background-color: #fefefe; margin: 3% auto; padding: 0; border-radius: 8px; width: 90%; max-width: 800px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
|
15
|
+
<!-- Header -->
|
|
16
|
+
<div style="background: #ffc107; color: #000; padding: 1.5rem; border-radius: 8px 8px 0 0;">
|
|
17
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
18
|
+
<h3 style="margin: 0;">🔄 Re-Execute Trigger</h3>
|
|
19
|
+
<button type="button" onclick="closeReExecuteModal('<%= form_id %>')"
|
|
20
|
+
style="background: none; border: none; color: #000; font-size: 1.5rem; cursor: pointer; padding: 0; width: 30px; height: 30px;">
|
|
21
|
+
×
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Body -->
|
|
27
|
+
<div style="padding: 1.5rem;">
|
|
28
|
+
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; margin-bottom: 1.5rem;">
|
|
29
|
+
<strong style="color: #856404;">Notice:</strong>
|
|
30
|
+
<p style="margin: 0.5rem 0 0 0; color: #856404;">
|
|
31
|
+
This will drop and re-create trigger '<strong><%= trigger.trigger_name %></strong>' based on the expected definition from the registry.
|
|
32
|
+
This can fix drift issues.
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<!-- Show Drift Diff -->
|
|
37
|
+
<% if drift_info[:expected_sql].present? %>
|
|
38
|
+
<div style="margin-bottom: 1.5rem;">
|
|
39
|
+
<h4 style="margin-bottom: 1rem;">SQL Comparison</h4>
|
|
40
|
+
|
|
41
|
+
<div style="margin-bottom: 1rem;">
|
|
42
|
+
<div style="color: #28a745; font-weight: 600; margin-bottom: 0.5rem;">✓ Expected SQL (will be applied):</div>
|
|
43
|
+
<pre style="background: #d4edda; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #c3e6cb; font-size: 0.85rem; max-height: 200px;"><code><%= drift_info[:expected_sql] %></code></pre>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div>
|
|
47
|
+
<div style="color: #dc3545; font-weight: 600; margin-bottom: 0.5rem;">✗ Current SQL (will be replaced):</div>
|
|
48
|
+
<pre style="background: #f8d7da; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #f5c6cb; font-size: 0.85rem; max-height: 200px;"><code><%= drift_info[:actual_sql] || "Not found in database" %></code></pre>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
|
|
53
|
+
<form id="<%= form_id %>-reason-form">
|
|
54
|
+
<div style="margin-bottom: 1rem;">
|
|
55
|
+
<label for="<%= form_id %>-reason" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
|
|
56
|
+
Reason <span style="color: #dc3545;">*</span>
|
|
57
|
+
</label>
|
|
58
|
+
<textarea id="<%= form_id %>-reason" name="reason" required rows="2"
|
|
59
|
+
placeholder="Why are you re-executing this trigger? This will be logged."
|
|
60
|
+
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: inherit;"></textarea>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<% if kill_switch_active? %>
|
|
64
|
+
<div style="margin-bottom: 1rem;">
|
|
65
|
+
<label for="<%= form_id %>-confirmation" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
|
|
66
|
+
Confirmation Text <span style="color: #dc3545;">*</span>
|
|
67
|
+
</label>
|
|
68
|
+
<input type="text" id="<%= form_id %>-confirmation" name="confirmation_text" required
|
|
69
|
+
placeholder="<%= expected_confirmation_text(:trigger_re_execute) %>"
|
|
70
|
+
autocomplete="off"
|
|
71
|
+
style="width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', monospace;">
|
|
72
|
+
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
73
|
+
Type: <code><%= expected_confirmation_text(:trigger_re_execute) %></code>
|
|
74
|
+
</small>
|
|
75
|
+
</div>
|
|
76
|
+
<% end %>
|
|
77
|
+
|
|
78
|
+
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
|
79
|
+
<button type="button" onclick="closeReExecuteModal('<%= form_id %>')"
|
|
80
|
+
style="padding: 0.75rem 1.5rem; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
|
81
|
+
Cancel
|
|
82
|
+
</button>
|
|
83
|
+
<button type="button" onclick="submitReExecute('<%= form_id %>')"
|
|
84
|
+
style="padding: 0.75rem 1.5rem; background: #ffc107; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">
|
|
85
|
+
Re-Execute Trigger
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</form>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<script>
|
|
94
|
+
function showReExecuteModal(formId) {
|
|
95
|
+
document.getElementById(formId + '-modal').style.display = 'block';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function closeReExecuteModal(formId) {
|
|
99
|
+
document.getElementById(formId + '-modal').style.display = 'none';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function submitReExecute(formId) {
|
|
103
|
+
const form = document.getElementById(formId);
|
|
104
|
+
const reasonForm = document.getElementById(formId + '-reason-form');
|
|
105
|
+
const reason = document.getElementById(formId + '-reason').value;
|
|
106
|
+
|
|
107
|
+
if (!reason.trim()) {
|
|
108
|
+
alert('Please provide a reason for re-executing this trigger.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
<% if kill_switch_active? %>
|
|
113
|
+
const confirmation = document.getElementById(formId + '-confirmation').value;
|
|
114
|
+
if (!confirmation.trim()) {
|
|
115
|
+
alert('Please provide the confirmation text.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Add confirmation to main form
|
|
120
|
+
const confirmationInput = document.createElement('input');
|
|
121
|
+
confirmationInput.type = 'hidden';
|
|
122
|
+
confirmationInput.name = 'confirmation_text';
|
|
123
|
+
confirmationInput.value = confirmation;
|
|
124
|
+
form.appendChild(confirmationInput);
|
|
125
|
+
<% end %>
|
|
126
|
+
|
|
127
|
+
// Add reason to main form
|
|
128
|
+
const reasonInput = document.createElement('input');
|
|
129
|
+
reasonInput.type = 'hidden';
|
|
130
|
+
reasonInput.name = 'reason';
|
|
131
|
+
reasonInput.value = reason;
|
|
132
|
+
form.appendChild(reasonInput);
|
|
133
|
+
|
|
134
|
+
form.submit();
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<div style="margin-bottom: 2rem;">
|
|
2
|
+
<h2>Trigger Details: <%= @trigger.trigger_name %></h2>
|
|
3
|
+
<%= link_to "← Back to Dashboard", dashboard_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; margin-right: 1rem;" %>
|
|
4
|
+
<%= link_to "View Table", table_path(@trigger.table_name), class: "btn", style: "background: #007bff; color: white; text-decoration: none;" %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<!-- Drift Warning -->
|
|
8
|
+
<% if @drift_info[:has_drift] %>
|
|
9
|
+
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1.5rem; border-radius: 4px; margin-bottom: 2rem;">
|
|
10
|
+
<h3 style="margin-top: 0; color: #856404;">⚠ Drift Detected</h3>
|
|
11
|
+
<p style="color: #856404; margin-bottom: 0;">
|
|
12
|
+
This trigger has drifted from its expected state. Type: <strong><%= @drift_info[:drift_type] %></strong>
|
|
13
|
+
</p>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<!-- Trigger Summary -->
|
|
18
|
+
<div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
|
|
19
|
+
<h3 style="margin-top: 0;">Summary</h3>
|
|
20
|
+
|
|
21
|
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-bottom: 1.5rem;">
|
|
22
|
+
<div>
|
|
23
|
+
<div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Status</div>
|
|
24
|
+
<div>
|
|
25
|
+
<% if @trigger.enabled %>
|
|
26
|
+
<span class="badge badge-success">Enabled</span>
|
|
27
|
+
<% else %>
|
|
28
|
+
<span class="badge badge-danger">Disabled</span>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div>
|
|
34
|
+
<div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Table</div>
|
|
35
|
+
<div><strong><%= @trigger.table_name %></strong></div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div>
|
|
39
|
+
<div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Version</div>
|
|
40
|
+
<div><strong><%= @trigger.version %></strong></div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div>
|
|
44
|
+
<div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Source</div>
|
|
45
|
+
<div><span class="badge badge-info"><%= @trigger.source %></span></div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<% if @trigger.environment.present? %>
|
|
50
|
+
<div style="margin-bottom: 1rem;">
|
|
51
|
+
<div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Environment</div>
|
|
52
|
+
<div><strong><%= @trigger.environment %></strong></div>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
|
|
56
|
+
<% if @trigger.installed_at.present? %>
|
|
57
|
+
<div style="margin-bottom: 1rem;">
|
|
58
|
+
<div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Installed At</div>
|
|
59
|
+
<div><%= @trigger.installed_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
63
|
+
<div>
|
|
64
|
+
<div style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.25rem;">Created At</div>
|
|
65
|
+
<div><%= @trigger.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<!-- Trigger Definition -->
|
|
70
|
+
<% if @trigger.definition.present? %>
|
|
71
|
+
<% definition = JSON.parse(@trigger.definition) rescue {} %>
|
|
72
|
+
<div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
|
|
73
|
+
<h3 style="margin-top: 0;">Trigger Configuration</h3>
|
|
74
|
+
|
|
75
|
+
<div style="margin-bottom: 1rem;">
|
|
76
|
+
<strong>Function:</strong>
|
|
77
|
+
<code style="background: #e9ecef; padding: 0.25rem 0.5rem; border-radius: 2px;"><%= definition["function_name"] || "N/A" %></code>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<% if definition["timing"].present? %>
|
|
81
|
+
<div style="margin-bottom: 1rem;">
|
|
82
|
+
<strong>Timing:</strong>
|
|
83
|
+
<span class="badge badge-info"><%= definition["timing"].upcase %></span>
|
|
84
|
+
</div>
|
|
85
|
+
<% end %>
|
|
86
|
+
|
|
87
|
+
<% if definition["events"].present? %>
|
|
88
|
+
<div style="margin-bottom: 1rem;">
|
|
89
|
+
<strong>Events:</strong>
|
|
90
|
+
<% definition["events"].each do |event| %>
|
|
91
|
+
<span class="badge badge-info" style="margin-left: 0.25rem;"><%= event.upcase %></span>
|
|
92
|
+
<% end %>
|
|
93
|
+
</div>
|
|
94
|
+
<% end %>
|
|
95
|
+
|
|
96
|
+
<% if definition["condition"].present? %>
|
|
97
|
+
<div style="margin-bottom: 1rem;">
|
|
98
|
+
<strong>Condition (WHEN):</strong>
|
|
99
|
+
<pre style="background: #f8f9fa; padding: 0.5rem; border-radius: 4px; margin-top: 0.25rem; overflow-x: auto;"><code><%= definition["condition"] %></code></pre>
|
|
100
|
+
</div>
|
|
101
|
+
<% end %>
|
|
102
|
+
|
|
103
|
+
<% if definition["environments"].present? %>
|
|
104
|
+
<div>
|
|
105
|
+
<strong>Environments:</strong>
|
|
106
|
+
<% definition["environments"].each do |env| %>
|
|
107
|
+
<span class="badge badge-secondary" style="margin-left: 0.25rem;"><%= env %></span>
|
|
108
|
+
<% end %>
|
|
109
|
+
</div>
|
|
110
|
+
<% end %>
|
|
111
|
+
</div>
|
|
112
|
+
<% end %>
|
|
113
|
+
|
|
114
|
+
<!-- SQL Diff (if drift detected) -->
|
|
115
|
+
<% if @drift_info[:has_drift] && @drift_info[:expected_sql].present? %>
|
|
116
|
+
<div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
|
|
117
|
+
<h3 style="margin-top: 0;">SQL Drift Comparison</h3>
|
|
118
|
+
|
|
119
|
+
<div style="margin-bottom: 1.5rem;">
|
|
120
|
+
<h4 style="color: #28a745; margin-bottom: 0.5rem;">Expected SQL (from DSL)</h4>
|
|
121
|
+
<pre style="background: #d4edda; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #c3e6cb;"><code><%= @drift_info[:expected_sql] %></code></pre>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div>
|
|
125
|
+
<h4 style="color: #dc3545; margin-bottom: 0.5rem;">Actual SQL (from database)</h4>
|
|
126
|
+
<pre style="background: #f8d7da; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #f5c6cb;"><code><%= @drift_info[:actual_sql] || "Not found in database" %></code></pre>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
<% end %>
|
|
130
|
+
|
|
131
|
+
<!-- Function Body -->
|
|
132
|
+
<% if @trigger.function_body.present? %>
|
|
133
|
+
<div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
|
|
134
|
+
<h3 style="margin-top: 0;">Function Body</h3>
|
|
135
|
+
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><%= @trigger.function_body %></code></pre>
|
|
136
|
+
</div>
|
|
137
|
+
<% end %>
|
|
138
|
+
|
|
139
|
+
<!-- Action Buttons -->
|
|
140
|
+
<% if PgSqlTriggers::Permissions.can?(current_actor, :enable_trigger) %>
|
|
141
|
+
<div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
142
|
+
<h3 style="margin-top: 0;">Actions</h3>
|
|
143
|
+
|
|
144
|
+
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
|
145
|
+
<% if @trigger.enabled %>
|
|
146
|
+
<% form_id = "trigger-detail-disable-form" %>
|
|
147
|
+
<%= form_with url: disable_trigger_path(@trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
|
|
148
|
+
<%= f.hidden_field :redirect_to, value: trigger_path(@trigger) %>
|
|
149
|
+
<button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
|
|
150
|
+
style="padding: 0.75rem 1.5rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
|
|
151
|
+
Disable Trigger
|
|
152
|
+
</button>
|
|
153
|
+
<% end %>
|
|
154
|
+
<%= render 'pg_sql_triggers/shared/confirmation_modal',
|
|
155
|
+
operation: :ui_trigger_disable,
|
|
156
|
+
form_id: form_id,
|
|
157
|
+
title: 'Disable Trigger',
|
|
158
|
+
message: "Are you sure you want to disable trigger '#{@trigger.trigger_name}'?" %>
|
|
159
|
+
<% else %>
|
|
160
|
+
<% form_id = "trigger-detail-enable-form" %>
|
|
161
|
+
<%= form_with url: enable_trigger_path(@trigger), method: :post, local: true, id: form_id, style: "margin: 0;" do |f| %>
|
|
162
|
+
<%= f.hidden_field :redirect_to, value: trigger_path(@trigger) %>
|
|
163
|
+
<button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
|
|
164
|
+
style="padding: 0.75rem 1.5rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;">
|
|
165
|
+
Enable Trigger
|
|
166
|
+
</button>
|
|
167
|
+
<% end %>
|
|
168
|
+
<%= render 'pg_sql_triggers/shared/confirmation_modal',
|
|
169
|
+
operation: :ui_trigger_enable,
|
|
170
|
+
form_id: form_id,
|
|
171
|
+
title: 'Enable Trigger',
|
|
172
|
+
message: "Are you sure you want to enable trigger '#{@trigger.trigger_name}'?" %>
|
|
173
|
+
<% end %>
|
|
174
|
+
|
|
175
|
+
<!-- Re-execute button (for drifted triggers) -->
|
|
176
|
+
<% if @drift_info[:has_drift] && PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
|
|
177
|
+
<%= render 'pg_sql_triggers/triggers/re_execute_modal', trigger: @trigger, drift_info: @drift_info %>
|
|
178
|
+
<% end %>
|
|
179
|
+
|
|
180
|
+
<!-- Drop button (Admin only) -->
|
|
181
|
+
<% if PgSqlTriggers::Permissions.can?(current_actor, :drop_trigger) %>
|
|
182
|
+
<%= render 'pg_sql_triggers/triggers/drop_modal', trigger: @trigger %>
|
|
183
|
+
<% end %>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -28,6 +28,15 @@ begin
|
|
|
28
28
|
post :redo
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
resources :triggers, only: [:show] do
|
|
33
|
+
member do
|
|
34
|
+
post :enable
|
|
35
|
+
post :disable
|
|
36
|
+
post :drop
|
|
37
|
+
post :re_execute
|
|
38
|
+
end
|
|
39
|
+
end
|
|
31
40
|
end
|
|
32
41
|
rescue ArgumentError => e
|
|
33
42
|
# Ignore duplicate route errors (routes may already be drawn in tests)
|
data/docs/api-reference.md
CHANGED
|
@@ -114,6 +114,102 @@ end
|
|
|
114
114
|
|
|
115
115
|
**Returns**: `true` if all triggers are valid
|
|
116
116
|
|
|
117
|
+
### `PgSqlTriggers::Registry.enable(trigger_name, actor:, confirmation: nil)`
|
|
118
|
+
|
|
119
|
+
Enables a trigger with permission and kill switch checks.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# Enable trigger
|
|
123
|
+
PgSqlTriggers::Registry.enable(
|
|
124
|
+
"users_email_validation",
|
|
125
|
+
actor: { type: "user", id: "admin@example.com" },
|
|
126
|
+
confirmation: "EXECUTE TRIGGER_ENABLE"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# With current user as actor
|
|
130
|
+
actor = { type: "user", id: current_user.email }
|
|
131
|
+
PgSqlTriggers::Registry.enable("billing_trigger", actor: actor, confirmation: "EXECUTE TRIGGER_ENABLE")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Parameters**:
|
|
135
|
+
- `trigger_name` (String): The name of the trigger to enable
|
|
136
|
+
- `actor` (Hash): Information about who is performing the operation (requires `:type` and `:id` keys)
|
|
137
|
+
- `confirmation` (String, optional): Kill switch confirmation text
|
|
138
|
+
|
|
139
|
+
**Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
|
|
140
|
+
|
|
141
|
+
**Returns**: `true` on success
|
|
142
|
+
|
|
143
|
+
### `PgSqlTriggers::Registry.disable(trigger_name, actor:, confirmation: nil)`
|
|
144
|
+
|
|
145
|
+
Disables a trigger with permission and kill switch checks.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
# Disable trigger
|
|
149
|
+
PgSqlTriggers::Registry.disable(
|
|
150
|
+
"users_email_validation",
|
|
151
|
+
actor: { type: "user", id: "admin@example.com" },
|
|
152
|
+
confirmation: "EXECUTE TRIGGER_DISABLE"
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Parameters**:
|
|
157
|
+
- `trigger_name` (String): The name of the trigger to disable
|
|
158
|
+
- `actor` (Hash): Information about who is performing the operation
|
|
159
|
+
- `confirmation` (String, optional): Kill switch confirmation text
|
|
160
|
+
|
|
161
|
+
**Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
|
|
162
|
+
|
|
163
|
+
**Returns**: `true` on success
|
|
164
|
+
|
|
165
|
+
### `PgSqlTriggers::Registry.drop(trigger_name, actor:, reason:, confirmation: nil)`
|
|
166
|
+
|
|
167
|
+
Drops a trigger from the database and removes it from the registry.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# Drop trigger
|
|
171
|
+
PgSqlTriggers::Registry.drop(
|
|
172
|
+
"old_trigger",
|
|
173
|
+
actor: { type: "user", id: "admin@example.com" },
|
|
174
|
+
reason: "No longer needed in production",
|
|
175
|
+
confirmation: "EXECUTE TRIGGER_DROP"
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Parameters**:
|
|
180
|
+
- `trigger_name` (String): The name of the trigger to drop
|
|
181
|
+
- `actor` (Hash): Information about who is performing the operation (Admin permission required)
|
|
182
|
+
- `reason` (String): Required explanation for why the trigger is being dropped (logged for audit)
|
|
183
|
+
- `confirmation` (String, optional): Kill switch confirmation text
|
|
184
|
+
|
|
185
|
+
**Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
|
|
186
|
+
|
|
187
|
+
**Returns**: `true` on success
|
|
188
|
+
|
|
189
|
+
### `PgSqlTriggers::Registry.re_execute(trigger_name, actor:, reason:, confirmation: nil)`
|
|
190
|
+
|
|
191
|
+
Re-executes a trigger by dropping and recreating it from the registry definition. Useful for fixing drifted triggers.
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Re-execute drifted trigger
|
|
195
|
+
PgSqlTriggers::Registry.re_execute(
|
|
196
|
+
"drifted_trigger",
|
|
197
|
+
actor: { type: "user", id: "admin@example.com" },
|
|
198
|
+
reason: "Fix drift detected in production",
|
|
199
|
+
confirmation: "EXECUTE TRIGGER_RE_EXECUTE"
|
|
200
|
+
)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Parameters**:
|
|
204
|
+
- `trigger_name` (String): The name of the trigger to re-execute
|
|
205
|
+
- `actor` (Hash): Information about who is performing the operation (Admin permission required)
|
|
206
|
+
- `reason` (String): Required explanation for why the trigger is being re-executed (logged for audit)
|
|
207
|
+
- `confirmation` (String, optional): Kill switch confirmation text
|
|
208
|
+
|
|
209
|
+
**Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
|
|
210
|
+
|
|
211
|
+
**Returns**: `true` on success
|
|
212
|
+
|
|
117
213
|
## Migrator API
|
|
118
214
|
|
|
119
215
|
The Migrator API manages trigger migrations programmatically.
|
|
@@ -318,6 +414,124 @@ end
|
|
|
318
414
|
|
|
319
415
|
**Returns**: Result of the block
|
|
320
416
|
|
|
417
|
+
## SQL Capsule API
|
|
418
|
+
|
|
419
|
+
The SQL Capsule API provides emergency SQL execution capabilities with safety checks.
|
|
420
|
+
|
|
421
|
+
### `PgSqlTriggers::SQL::Capsule.new(name:, environment:, purpose:, sql:, created_at: nil)`
|
|
422
|
+
|
|
423
|
+
Creates a new SQL capsule for emergency operations.
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
capsule = PgSqlTriggers::SQL::Capsule.new(
|
|
427
|
+
name: "fix_user_permissions",
|
|
428
|
+
environment: "production",
|
|
429
|
+
purpose: "Emergency fix for user permission issue after deployment",
|
|
430
|
+
sql: "UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';"
|
|
431
|
+
)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**Parameters**:
|
|
435
|
+
- `name` (String): Unique name for the capsule (alphanumeric, underscores, hyphens only)
|
|
436
|
+
- `environment` (String): Target environment (e.g., "production", "staging")
|
|
437
|
+
- `purpose` (String): Description of what the capsule does and why (required for audit trail)
|
|
438
|
+
- `sql` (String): The SQL statement(s) to execute
|
|
439
|
+
- `created_at` (Time, optional): Creation timestamp (defaults to current time)
|
|
440
|
+
|
|
441
|
+
**Raises**: `ArgumentError` if validation fails
|
|
442
|
+
|
|
443
|
+
### `capsule.checksum`
|
|
444
|
+
|
|
445
|
+
Returns the SHA256 checksum of the SQL content.
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
capsule = PgSqlTriggers::SQL::Capsule.new(name: "fix", ...)
|
|
449
|
+
puts capsule.checksum
|
|
450
|
+
# => "a3f5b8c9d2e..."
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
**Returns**: String (SHA256 hash)
|
|
454
|
+
|
|
455
|
+
### `capsule.to_h`
|
|
456
|
+
|
|
457
|
+
Converts the capsule to a hash for storage or serialization.
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
capsule_data = capsule.to_h
|
|
461
|
+
# => {
|
|
462
|
+
# name: "fix_user_permissions",
|
|
463
|
+
# environment: "production",
|
|
464
|
+
# purpose: "Emergency fix...",
|
|
465
|
+
# sql: "UPDATE users...",
|
|
466
|
+
# checksum: "a3f5b8c9d2e...",
|
|
467
|
+
# created_at: 2026-01-01 12:00:00 UTC
|
|
468
|
+
# }
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**Returns**: Hash
|
|
472
|
+
|
|
473
|
+
## SQL Executor API
|
|
474
|
+
|
|
475
|
+
The SQL Executor API handles safe execution of SQL capsules with comprehensive logging.
|
|
476
|
+
|
|
477
|
+
### `PgSqlTriggers::SQL::Executor.execute(capsule, actor:, confirmation: nil, dry_run: false)`
|
|
478
|
+
|
|
479
|
+
Executes a SQL capsule with safety checks and logging.
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
capsule = PgSqlTriggers::SQL::Capsule.new(
|
|
483
|
+
name: "emergency_fix",
|
|
484
|
+
environment: "production",
|
|
485
|
+
purpose: "Fix critical data corruption",
|
|
486
|
+
sql: "UPDATE orders SET status = 'completed' WHERE id IN (123, 456);"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Execute the capsule
|
|
490
|
+
result = PgSqlTriggers::SQL::Executor.execute(
|
|
491
|
+
capsule,
|
|
492
|
+
actor: { type: "user", id: "admin@example.com" },
|
|
493
|
+
confirmation: "EXECUTE SQL"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if result[:success]
|
|
497
|
+
puts "SQL executed successfully"
|
|
498
|
+
puts "Rows affected: #{result[:data][:rows_affected]}"
|
|
499
|
+
else
|
|
500
|
+
puts "Execution failed: #{result[:message]}"
|
|
501
|
+
end
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**Parameters**:
|
|
505
|
+
- `capsule` (Capsule): The SQL capsule to execute
|
|
506
|
+
- `actor` (Hash): Information about who is executing (Admin permission required)
|
|
507
|
+
- `confirmation` (String, optional): Kill switch confirmation text
|
|
508
|
+
- `dry_run` (Boolean): If true, validates without executing (default: false)
|
|
509
|
+
|
|
510
|
+
**Returns**: Hash with `:success`, `:message`, and `:data` keys
|
|
511
|
+
|
|
512
|
+
**Raises**: Permission and kill switch errors are returned in the result hash
|
|
513
|
+
|
|
514
|
+
### `PgSqlTriggers::SQL::Executor.execute_capsule(capsule_name, actor:, confirmation: nil, dry_run: false)`
|
|
515
|
+
|
|
516
|
+
Executes a previously stored SQL capsule by name from the registry.
|
|
517
|
+
|
|
518
|
+
```ruby
|
|
519
|
+
# Execute a capsule stored in the registry
|
|
520
|
+
result = PgSqlTriggers::SQL::Executor.execute_capsule(
|
|
521
|
+
"emergency_fix",
|
|
522
|
+
actor: { type: "user", id: "admin@example.com" },
|
|
523
|
+
confirmation: "EXECUTE SQL"
|
|
524
|
+
)
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
**Parameters**:
|
|
528
|
+
- `capsule_name` (String): Name of the capsule in the registry
|
|
529
|
+
- `actor` (Hash): Information about who is executing
|
|
530
|
+
- `confirmation` (String, optional): Kill switch confirmation text
|
|
531
|
+
- `dry_run` (Boolean): If true, validates without executing
|
|
532
|
+
|
|
533
|
+
**Returns**: Hash with execution results
|
|
534
|
+
|
|
321
535
|
## DSL API
|
|
322
536
|
|
|
323
537
|
The DSL API is used to define triggers in your application.
|
|
@@ -487,17 +701,51 @@ trigger.disable!(confirmation: "EXECUTE TRIGGER_DISABLE")
|
|
|
487
701
|
|
|
488
702
|
**Returns**: `true` on success
|
|
489
703
|
|
|
490
|
-
#### `drop!(confirmation: nil)`
|
|
704
|
+
#### `drop!(reason:, confirmation: nil, actor: nil)`
|
|
491
705
|
|
|
492
|
-
Drops the trigger from the database.
|
|
706
|
+
Drops the trigger from the database and removes it from the registry.
|
|
493
707
|
|
|
494
708
|
```ruby
|
|
495
|
-
trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "
|
|
496
|
-
|
|
709
|
+
trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "old_trigger")
|
|
710
|
+
|
|
711
|
+
# Drop with reason (required)
|
|
712
|
+
trigger.drop!(
|
|
713
|
+
reason: "No longer needed in production",
|
|
714
|
+
confirmation: "EXECUTE TRIGGER_DROP",
|
|
715
|
+
actor: { type: "user", id: "admin@example.com" }
|
|
716
|
+
)
|
|
497
717
|
```
|
|
498
718
|
|
|
499
719
|
**Parameters**:
|
|
720
|
+
- `reason` (String, required): Explanation for why the trigger is being dropped (logged for audit trail)
|
|
500
721
|
- `confirmation` (String, optional): Kill switch confirmation text
|
|
722
|
+
- `actor` (Hash, optional): Information about who is performing the operation
|
|
723
|
+
|
|
724
|
+
**Raises**: `ArgumentError` if reason is blank, `PgSqlTriggers::KillSwitchError`
|
|
725
|
+
|
|
726
|
+
**Returns**: `true` on success
|
|
727
|
+
|
|
728
|
+
#### `re_execute!(reason:, confirmation: nil, actor: nil)`
|
|
729
|
+
|
|
730
|
+
Re-executes the trigger by dropping and recreating it from the registry definition. Useful for fixing drifted triggers.
|
|
731
|
+
|
|
732
|
+
```ruby
|
|
733
|
+
trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "drifted_trigger")
|
|
734
|
+
|
|
735
|
+
# Re-execute to fix drift
|
|
736
|
+
trigger.re_execute!(
|
|
737
|
+
reason: "Fix drift detected after manual database changes",
|
|
738
|
+
confirmation: "EXECUTE TRIGGER_RE_EXECUTE",
|
|
739
|
+
actor: { type: "user", id: "admin@example.com" }
|
|
740
|
+
)
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**Parameters**:
|
|
744
|
+
- `reason` (String, required): Explanation for why the trigger is being re-executed (logged for audit trail)
|
|
745
|
+
- `confirmation` (String, optional): Kill switch confirmation text
|
|
746
|
+
- `actor` (Hash, optional): Information about who is performing the operation
|
|
747
|
+
|
|
748
|
+
**Raises**: `ArgumentError` if reason is blank or function_body is missing, `PgSqlTriggers::KillSwitchError`, `StandardError`
|
|
501
749
|
|
|
502
750
|
**Returns**: `true` on success
|
|
503
751
|
|