pg_sql_triggers 1.3.0 → 1.4.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/CHANGELOG.md +253 -1
- data/GEM_ANALYSIS.md +368 -0
- data/README.md +20 -23
- data/app/models/pg_sql_triggers/trigger_registry.rb +42 -6
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +1 -4
- data/app/views/pg_sql_triggers/tables/index.html.erb +1 -4
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
- data/config/routes.rb +0 -14
- data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/api-reference.md +44 -153
- data/docs/configuration.md +24 -3
- data/docs/getting-started.md +17 -16
- data/docs/usage-guide.md +38 -67
- data/docs/web-ui.md +3 -103
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -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/pg_sql_triggers/drift/db_queries.rb +12 -8
- data/lib/pg_sql_triggers/drift/detector.rb +51 -38
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
- data/lib/pg_sql_triggers/engine.rb +14 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
- data/lib/pg_sql_triggers/migrator.rb +53 -6
- data/lib/pg_sql_triggers/registry/manager.rb +36 -11
- data/lib/pg_sql_triggers/registry/validator.rb +62 -5
- data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -275
- data/lib/pg_sql_triggers/sql.rb +0 -6
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +4 -1
- data/pg_sql_triggers.gemspec +53 -0
- metadata +7 -13
- 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
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
<h2>Preview Generated Trigger</h2>
|
|
2
|
-
|
|
3
|
-
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; margin-bottom: 2rem;">
|
|
4
|
-
<strong>Review before generating:</strong>
|
|
5
|
-
<ul style="margin: 0.5rem 0 0 1rem; padding: 0;">
|
|
6
|
-
<li>Migration file will be created at: <code><%= @file_paths[:migration] %></code></li>
|
|
7
|
-
<li>DSL file will be created at: <code><%= @file_paths[:dsl] %></code></li>
|
|
8
|
-
<li>Trigger will be registered with source: <strong>dsl</strong></li>
|
|
9
|
-
</ul>
|
|
10
|
-
</div>
|
|
11
|
-
|
|
12
|
-
<!-- Actions -->
|
|
13
|
-
<%= form_with url: generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form, id: "generator-create-form", local: true do |f| %>
|
|
14
|
-
<!-- DSL Preview -->
|
|
15
|
-
<div style="margin-bottom: 2rem;">
|
|
16
|
-
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
|
17
|
-
<span>DSL Definition</span>
|
|
18
|
-
<span class="badge badge-info">Ruby</span>
|
|
19
|
-
<small style="color: #6c757d; font-weight: normal;">(Updates automatically when timing or condition changes)</small>
|
|
20
|
-
</h3>
|
|
21
|
-
<pre id="dsl-preview" style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #dee2e6; min-height: 150px;"><code><%= @dsl_content %></code></pre>
|
|
22
|
-
</div>
|
|
23
|
-
|
|
24
|
-
<!-- Trigger Configuration Summary (Editable) -->
|
|
25
|
-
<div style="margin-bottom: 2rem; padding: 1.5rem; background: #f8f9fa; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
26
|
-
<h3 style="margin-top: 0; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
27
|
-
<span>Trigger Configuration</span>
|
|
28
|
-
<small style="color: #6c757d; font-weight: normal;">(Editable - changes will update preview)</small>
|
|
29
|
-
</h3>
|
|
30
|
-
|
|
31
|
-
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
|
|
32
|
-
<!-- Timing Field -->
|
|
33
|
-
<div>
|
|
34
|
-
<%= f.label :timing, "Trigger Timing *", style: "display: block; font-weight: 500; margin-bottom: 0.5rem; color: #495057;" %>
|
|
35
|
-
<%= f.select :timing,
|
|
36
|
-
options_for_select([["Before", "before"], ["After", "after"]], @form.timing || "before"),
|
|
37
|
-
{},
|
|
38
|
-
{
|
|
39
|
-
required: true,
|
|
40
|
-
id: "preview-timing-select",
|
|
41
|
-
style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; background: white; font-size: 0.875rem;",
|
|
42
|
-
onchange: "updatePreview()"
|
|
43
|
-
} %>
|
|
44
|
-
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
45
|
-
When the trigger should fire relative to the event
|
|
46
|
-
</small>
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
<!-- Read-only fields -->
|
|
50
|
-
<div>
|
|
51
|
-
<strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Events:</strong>
|
|
52
|
-
<div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
53
|
-
<span style="color: #495057; font-weight: 500;"><%= Array(@form.events).compact_blank.map(&:upcase).join(", ") %></span>
|
|
54
|
-
</div>
|
|
55
|
-
</div>
|
|
56
|
-
|
|
57
|
-
<div>
|
|
58
|
-
<strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Table:</strong>
|
|
59
|
-
<div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
60
|
-
<span style="color: #495057; font-weight: 500;"><%= @form.table_name %></span>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
|
|
64
|
-
<div>
|
|
65
|
-
<strong style="color: #495057; display: block; margin-bottom: 0.5rem;">Function:</strong>
|
|
66
|
-
<div style="padding: 0.5rem; background: white; border-radius: 4px; border: 1px solid #dee2e6;">
|
|
67
|
-
<span style="color: #495057; font-weight: 500;"><%= @form.function_name %></span>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
|
|
71
|
-
<!-- Condition Field (Editable) -->
|
|
72
|
-
<div style="grid-column: 1 / -1;">
|
|
73
|
-
<%= f.label :condition, "WHEN Condition (Optional)", style: "display: block; font-weight: 500; margin-bottom: 0.5rem; color: #495057;" %>
|
|
74
|
-
<%= f.text_area :condition,
|
|
75
|
-
value: @form.condition,
|
|
76
|
-
placeholder: "e.g., NEW.email IS NOT NULL OR NEW.status = 'active'",
|
|
77
|
-
rows: 3,
|
|
78
|
-
id: "preview-condition-textarea",
|
|
79
|
-
style: "width: 100%; padding: 0.75rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; font-size: 0.875rem; background: white; resize: vertical;",
|
|
80
|
-
oninput: "updatePreview()" %>
|
|
81
|
-
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
82
|
-
Optional SQL condition. Leave empty to fire on all rows. Changes update the DSL preview above.
|
|
83
|
-
</small>
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
87
|
-
<!-- SQL Validation Result -->
|
|
88
|
-
<% if @sql_validation %>
|
|
89
|
-
<div style="margin-bottom: 2rem; padding: 1rem; border-radius: 4px; <%= @sql_validation[:valid] ? 'background: #d4edda; border-left: 4px solid #28a745;' : 'background: #f8d7da; border-left: 4px solid #dc3545;' %>">
|
|
90
|
-
<strong style="<%= @sql_validation[:valid] ? 'color: #155724;' : 'color: #721c24;' %>">
|
|
91
|
-
<%= @sql_validation[:valid] ? '✓ SQL Validation Passed' : '✗ SQL Validation Failed' %>
|
|
92
|
-
</strong>
|
|
93
|
-
<% if @sql_validation[:valid] %>
|
|
94
|
-
<p style="color: #155724; margin: 0.5rem 0 0 0; font-size: 0.875rem;">
|
|
95
|
-
<%= @sql_validation[:message] || 'Function syntax is valid' %>
|
|
96
|
-
</p>
|
|
97
|
-
<% else %>
|
|
98
|
-
<p style="color: #721c24; margin: 0.5rem 0 0 0; font-size: 0.875rem;">
|
|
99
|
-
<%= @sql_validation[:error] || 'Function syntax is invalid' %>
|
|
100
|
-
</p>
|
|
101
|
-
<% end %>
|
|
102
|
-
</div>
|
|
103
|
-
<% end %>
|
|
104
|
-
|
|
105
|
-
<!-- Function Preview -->
|
|
106
|
-
<div style="margin-bottom: 2rem;">
|
|
107
|
-
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
|
108
|
-
<span>PL/pgSQL Function</span>
|
|
109
|
-
<span class="badge badge-info">SQL</span>
|
|
110
|
-
<small style="color: #6c757d; font-weight: normal;">(Editable)</small>
|
|
111
|
-
</h3>
|
|
112
|
-
<p style="color: #6c757d; margin-bottom: 0.5rem; font-size: 0.875rem;">
|
|
113
|
-
You can edit the function body below before generating the trigger files.
|
|
114
|
-
</p>
|
|
115
|
-
<%= f.text_area :function_body,
|
|
116
|
-
value: @function_content,
|
|
117
|
-
rows: 20,
|
|
118
|
-
required: true,
|
|
119
|
-
style: "width: 100%; padding: 1rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; font-size: 0.875rem; line-height: 1.5; background: #f8f9fa; resize: vertical;" %>
|
|
120
|
-
</div>
|
|
121
|
-
<%= f.hidden_field :trigger_name, value: @form.trigger_name %>
|
|
122
|
-
<%= f.hidden_field :table_name, value: @form.table_name %>
|
|
123
|
-
<%= f.hidden_field :function_name, value: @form.function_name %>
|
|
124
|
-
<%= f.hidden_field :version, value: @form.version %>
|
|
125
|
-
<%= f.hidden_field :enabled, value: @form.enabled %>
|
|
126
|
-
<%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
|
|
127
|
-
<% Array(@form.events).reject(&:blank?).each do |event| %>
|
|
128
|
-
<%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
|
|
129
|
-
<% end %>
|
|
130
|
-
<% Array(@form.environments).reject(&:blank?).each do |env| %>
|
|
131
|
-
<%= hidden_field_tag "pg_sql_triggers_generator_form[environments][]", env %>
|
|
132
|
-
<% end %>
|
|
133
|
-
|
|
134
|
-
<div style="display: flex; gap: 1rem;">
|
|
135
|
-
<button type="button" onclick="showKillSwitchModal('generator-create-form')" class="btn btn-success"
|
|
136
|
-
style="padding: 0.75rem 1.5rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">
|
|
137
|
-
Generate Files
|
|
138
|
-
</button>
|
|
139
|
-
</div>
|
|
140
|
-
<% end %>
|
|
141
|
-
|
|
142
|
-
<!-- Separate form for "Back to Edit" to avoid nested forms -->
|
|
143
|
-
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
|
144
|
-
<%= form_with url: preview_generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form, id: "back-to-edit-form", local: true, style: "margin: 0;" do |f| %>
|
|
145
|
-
<%= f.hidden_field :trigger_name, value: @form.trigger_name %>
|
|
146
|
-
<%= f.hidden_field :table_name, value: @form.table_name %>
|
|
147
|
-
<%= f.hidden_field :function_name, value: @form.function_name %>
|
|
148
|
-
<%= f.hidden_field :version, value: @form.version %>
|
|
149
|
-
<%= f.hidden_field :enabled, value: @form.enabled %>
|
|
150
|
-
<%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
|
|
151
|
-
<%= f.hidden_field :timing, id: "back-to-edit-timing" %>
|
|
152
|
-
<%= f.hidden_field :condition, id: "back-to-edit-condition" %>
|
|
153
|
-
<%= f.hidden_field :function_body, id: "back-to-edit-function-body" %>
|
|
154
|
-
<% Array(@form.events).reject(&:blank?).each do |event| %>
|
|
155
|
-
<%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
|
|
156
|
-
<% end %>
|
|
157
|
-
<% Array(@form.environments).reject(&:blank?).each do |env| %>
|
|
158
|
-
<%= hidden_field_tag "pg_sql_triggers_generator_form[environments][]", env %>
|
|
159
|
-
<% end %>
|
|
160
|
-
<%= hidden_field_tag :back_to_edit, "1" %>
|
|
161
|
-
<button type="submit" class="btn" style="background: #6c757d; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer;">
|
|
162
|
-
Back to Edit
|
|
163
|
-
</button>
|
|
164
|
-
<% end %>
|
|
165
|
-
<%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 4px;" %>
|
|
166
|
-
</div>
|
|
167
|
-
|
|
168
|
-
<%= render 'pg_sql_triggers/shared/confirmation_modal',
|
|
169
|
-
operation: :ui_trigger_generate,
|
|
170
|
-
form_id: 'generator-create-form',
|
|
171
|
-
title: 'Generate Trigger Files',
|
|
172
|
-
message: 'This will create files on disk and register the trigger. Continue?' %>
|
|
173
|
-
|
|
174
|
-
<script>
|
|
175
|
-
(function() {
|
|
176
|
-
'use strict';
|
|
177
|
-
|
|
178
|
-
// Store the original DSL content
|
|
179
|
-
const originalDsl = <%= raw @dsl_content.to_json %>;
|
|
180
|
-
|
|
181
|
-
// Extract base DSL parts
|
|
182
|
-
const triggerName = '<%= @form.trigger_name %>';
|
|
183
|
-
const tableName = '<%= @form.table_name %>';
|
|
184
|
-
const functionName = '<%= @form.function_name %>';
|
|
185
|
-
const eventsList = '<%= Array(@form.events).compact_blank.map { |e| ":#{e}" }.join(", ") %>';
|
|
186
|
-
const version = <%= @form.version %>;
|
|
187
|
-
const enabled = <%= @form.enabled %>;
|
|
188
|
-
const environmentsList = '<%= @form.environments.compact_blank.map { |e| ":#{e}" }.join(", ") %>';
|
|
189
|
-
const functionRef = /^[a-z0-9_]+$/.test(functionName) ? `:${functionName}` : `"${functionName}"`;
|
|
190
|
-
|
|
191
|
-
function updatePreview() {
|
|
192
|
-
const timingSelect = document.getElementById('preview-timing-select');
|
|
193
|
-
const conditionTextarea = document.getElementById('preview-condition-textarea');
|
|
194
|
-
const dslPreview = document.getElementById('dsl-preview');
|
|
195
|
-
|
|
196
|
-
if (!timingSelect || !conditionTextarea || !dslPreview) return;
|
|
197
|
-
|
|
198
|
-
const timing = timingSelect.value || 'before';
|
|
199
|
-
const condition = conditionTextarea.value.trim();
|
|
200
|
-
|
|
201
|
-
// Build DSL content
|
|
202
|
-
const now = new Date();
|
|
203
|
-
const timestamp = now.toISOString().slice(0, 19).replace('T', ' ');
|
|
204
|
-
|
|
205
|
-
let dslContent = `# frozen_string_literal: true
|
|
206
|
-
|
|
207
|
-
# Generated by pg_sql_triggers on ${timestamp}
|
|
208
|
-
PgSqlTriggers::DSL.pg_sql_trigger "${triggerName}" do
|
|
209
|
-
table :${tableName}
|
|
210
|
-
on ${eventsList}
|
|
211
|
-
function ${functionRef}
|
|
212
|
-
|
|
213
|
-
version ${version}
|
|
214
|
-
enabled ${enabled}
|
|
215
|
-
timing :${timing}`;
|
|
216
|
-
|
|
217
|
-
if (environmentsList && environmentsList.length > 0) {
|
|
218
|
-
dslContent += `\n when_env ${environmentsList}`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (condition) {
|
|
222
|
-
// Escape quotes in condition
|
|
223
|
-
const escapedCondition = condition.replace(/"/g, '\\"');
|
|
224
|
-
dslContent += `\n when_condition "${escapedCondition}"`;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
dslContent += `\nend\n`;
|
|
228
|
-
|
|
229
|
-
// Update the preview
|
|
230
|
-
const codeElement = dslPreview.querySelector('code');
|
|
231
|
-
if (codeElement) {
|
|
232
|
-
codeElement.textContent = dslContent;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Sync form values to back-to-edit form before submission
|
|
237
|
-
function syncBackToEditForm() {
|
|
238
|
-
const timingSelect = document.getElementById('preview-timing-select');
|
|
239
|
-
const conditionTextarea = document.getElementById('preview-condition-textarea');
|
|
240
|
-
const functionBodyTextarea = document.querySelector('#generator-create-form textarea[name="pg_sql_triggers_generator_form[function_body]"]');
|
|
241
|
-
const backToEditForm = document.getElementById('back-to-edit-form');
|
|
242
|
-
|
|
243
|
-
if (!backToEditForm) return;
|
|
244
|
-
|
|
245
|
-
// Update timing
|
|
246
|
-
const backToEditTiming = document.getElementById('back-to-edit-timing');
|
|
247
|
-
if (backToEditTiming && timingSelect) {
|
|
248
|
-
backToEditTiming.value = timingSelect.value || 'before';
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Update condition
|
|
252
|
-
const backToEditCondition = document.getElementById('back-to-edit-condition');
|
|
253
|
-
if (backToEditCondition && conditionTextarea) {
|
|
254
|
-
backToEditCondition.value = conditionTextarea.value || '';
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Update function_body - get from the create form textarea
|
|
258
|
-
const backToEditFunctionBody = document.getElementById('back-to-edit-function-body');
|
|
259
|
-
if (backToEditFunctionBody) {
|
|
260
|
-
if (functionBodyTextarea) {
|
|
261
|
-
backToEditFunctionBody.value = functionBodyTextarea.value || '';
|
|
262
|
-
} else {
|
|
263
|
-
// Fallback: try to find by name attribute
|
|
264
|
-
const createFormFunctionBody = document.querySelector('textarea[name="pg_sql_triggers_generator_form[function_body]"]');
|
|
265
|
-
if (createFormFunctionBody) {
|
|
266
|
-
backToEditFunctionBody.value = createFormFunctionBody.value || '';
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Initialize on DOM ready
|
|
273
|
-
document.addEventListener('DOMContentLoaded', function() {
|
|
274
|
-
const timingSelect = document.getElementById('preview-timing-select');
|
|
275
|
-
const conditionTextarea = document.getElementById('preview-condition-textarea');
|
|
276
|
-
const backToEditForm = document.getElementById('back-to-edit-form');
|
|
277
|
-
|
|
278
|
-
if (timingSelect) {
|
|
279
|
-
timingSelect.addEventListener('change', updatePreview);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (conditionTextarea) {
|
|
283
|
-
conditionTextarea.addEventListener('input', updatePreview);
|
|
284
|
-
// Also update on paste
|
|
285
|
-
conditionTextarea.addEventListener('paste', function() {
|
|
286
|
-
setTimeout(updatePreview, 10);
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Sync form values before submitting back-to-edit form
|
|
291
|
-
if (backToEditForm) {
|
|
292
|
-
backToEditForm.addEventListener('submit', function(e) {
|
|
293
|
-
syncBackToEditForm();
|
|
294
|
-
// Ensure CSRF token is included
|
|
295
|
-
if (typeof window.ensureCsrfToken === 'function') {
|
|
296
|
-
window.ensureCsrfToken(backToEditForm);
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Initial update
|
|
302
|
-
updatePreview();
|
|
303
|
-
});
|
|
304
|
-
})();
|
|
305
|
-
</script>
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
<div style="max-width: 900px; margin: 0 auto;">
|
|
2
|
-
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
|
3
|
-
<h2 style="margin: 0;">Create SQL Capsule</h2>
|
|
4
|
-
<%= link_to "← Back to Dashboard", dashboard_path, style: "color: #007bff; text-decoration: none;" %>
|
|
5
|
-
</div>
|
|
6
|
-
|
|
7
|
-
<div style="background: #fff3cd; border: 1px solid #ffc107; padding: 1rem; border-radius: 4px; margin-bottom: 2rem;">
|
|
8
|
-
<strong>⚠️ Warning:</strong> SQL Capsules allow direct SQL execution. Use with extreme caution.
|
|
9
|
-
This feature is intended for emergency operations only.
|
|
10
|
-
</div>
|
|
11
|
-
|
|
12
|
-
<%= form_with url: sql_capsules_path, method: :post, local: true do |f| %>
|
|
13
|
-
<div style="background: white; padding: 2rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
14
|
-
|
|
15
|
-
<div style="margin-bottom: 1.5rem;">
|
|
16
|
-
<label for="name" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
|
|
17
|
-
Capsule Name <span style="color: #dc3545;">*</span>
|
|
18
|
-
</label>
|
|
19
|
-
<%= text_field_tag :name, @capsule_name,
|
|
20
|
-
placeholder: "e.g., fix_user_permissions",
|
|
21
|
-
required: true,
|
|
22
|
-
pattern: "[a-zA-Z0-9_-]+",
|
|
23
|
-
autocomplete: "off",
|
|
24
|
-
style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
|
|
25
|
-
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
26
|
-
Letters, numbers, underscores, and hyphens only
|
|
27
|
-
</small>
|
|
28
|
-
</div>
|
|
29
|
-
|
|
30
|
-
<div style="margin-bottom: 1.5rem;">
|
|
31
|
-
<label for="environment" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
|
|
32
|
-
Environment <span style="color: #dc3545;">*</span>
|
|
33
|
-
</label>
|
|
34
|
-
<%= text_field_tag :environment, @environment,
|
|
35
|
-
placeholder: "e.g., production",
|
|
36
|
-
required: true,
|
|
37
|
-
autocomplete: "off",
|
|
38
|
-
style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
|
|
39
|
-
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
40
|
-
The environment this capsule is intended for
|
|
41
|
-
</small>
|
|
42
|
-
</div>
|
|
43
|
-
|
|
44
|
-
<div style="margin-bottom: 1.5rem;">
|
|
45
|
-
<label for="purpose" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
|
|
46
|
-
Purpose <span style="color: #dc3545;">*</span>
|
|
47
|
-
</label>
|
|
48
|
-
<%= text_area_tag :purpose, @purpose,
|
|
49
|
-
placeholder: "Describe what this SQL does and why it's needed...",
|
|
50
|
-
required: true,
|
|
51
|
-
rows: 3,
|
|
52
|
-
style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: inherit;" %>
|
|
53
|
-
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
54
|
-
Detailed description of the operation and its purpose
|
|
55
|
-
</small>
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
<div style="margin-bottom: 1.5rem;">
|
|
59
|
-
<label for="sql" style="display: block; font-weight: 600; margin-bottom: 0.5rem;">
|
|
60
|
-
SQL Statement <span style="color: #dc3545;">*</span>
|
|
61
|
-
</label>
|
|
62
|
-
<%= text_area_tag :sql, @sql,
|
|
63
|
-
placeholder: "-- Enter your SQL statement here\nSELECT * FROM users WHERE ...",
|
|
64
|
-
required: true,
|
|
65
|
-
rows: 12,
|
|
66
|
-
style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 0.9rem;" %>
|
|
67
|
-
<small style="color: #6c757d; display: block; margin-top: 0.25rem;">
|
|
68
|
-
The SQL to execute. Review carefully before saving.
|
|
69
|
-
</small>
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
|
73
|
-
<%= link_to "Cancel", dashboard_path,
|
|
74
|
-
style: "padding: 0.75rem 1.5rem; background: #6c757d; color: white; text-decoration: none; border-radius: 4px;" %>
|
|
75
|
-
<%= button_tag "Create Capsule",
|
|
76
|
-
type: "submit",
|
|
77
|
-
style: "padding: 0.75rem 1.5rem; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem;" %>
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
80
|
-
<% end %>
|
|
81
|
-
</div>
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
<div style="max-width: 900px; margin: 0 auto;">
|
|
2
|
-
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
|
3
|
-
<h2 style="margin: 0;">SQL Capsule: <%= @capsule.name %></h2>
|
|
4
|
-
<%= link_to "← Back to Dashboard", dashboard_path, style: "color: #007bff; text-decoration: none;" %>
|
|
5
|
-
</div>
|
|
6
|
-
|
|
7
|
-
<div style="background: white; padding: 2rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
|
|
8
|
-
<h3 style="margin-top: 0; margin-bottom: 1rem; color: #495057;">Capsule Details</h3>
|
|
9
|
-
|
|
10
|
-
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 1rem; margin-bottom: 1rem;">
|
|
11
|
-
<div style="font-weight: 600; color: #6c757d;">Name:</div>
|
|
12
|
-
<div><%= @capsule.name %></div>
|
|
13
|
-
|
|
14
|
-
<div style="font-weight: 600; color: #6c757d;">Environment:</div>
|
|
15
|
-
<div><span class="badge badge-info"><%= @capsule.environment %></span></div>
|
|
16
|
-
|
|
17
|
-
<div style="font-weight: 600; color: #6c757d;">Created:</div>
|
|
18
|
-
<div><%= @capsule.created_at.strftime("%Y-%m-%d %H:%M:%S %Z") %></div>
|
|
19
|
-
|
|
20
|
-
<div style="font-weight: 600; color: #6c757d;">Checksum:</div>
|
|
21
|
-
<div style="font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 0.85rem; color: #6c757d;">
|
|
22
|
-
<%= @checksum %>
|
|
23
|
-
</div>
|
|
24
|
-
</div>
|
|
25
|
-
|
|
26
|
-
<div style="margin-top: 1.5rem;">
|
|
27
|
-
<div style="font-weight: 600; color: #6c757d; margin-bottom: 0.5rem;">Purpose:</div>
|
|
28
|
-
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; border-left: 3px solid #007bff;">
|
|
29
|
-
<%= @capsule.purpose %>
|
|
30
|
-
</div>
|
|
31
|
-
</div>
|
|
32
|
-
|
|
33
|
-
<div style="margin-top: 1.5rem;">
|
|
34
|
-
<div style="font-weight: 600; color: #6c757d; margin-bottom: 0.5rem;">SQL Statement:</div>
|
|
35
|
-
<div style="background: #f8f9fa; padding: 1rem; border-radius: 4px; border-left: 3px solid #28a745; overflow-x: auto;">
|
|
36
|
-
<pre style="margin: 0; font-family: 'Monaco', 'Menlo', 'Courier New', monospace; font-size: 0.9rem; white-space: pre-wrap; word-wrap: break-word;"><%= @capsule.sql %></pre>
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
</div>
|
|
40
|
-
|
|
41
|
-
<% if @can_execute %>
|
|
42
|
-
<div style="background: #fff3cd; border: 1px solid #ffc107; padding: 1.5rem; border-radius: 4px; margin-bottom: 2rem;">
|
|
43
|
-
<h4 style="margin-top: 0; color: #856404;">⚠️ Execution Warning</h4>
|
|
44
|
-
<p style="margin-bottom: 1rem; color: #856404;">
|
|
45
|
-
This will execute the SQL statement directly against the database.
|
|
46
|
-
This operation cannot be undone. Make sure you understand the implications.
|
|
47
|
-
</p>
|
|
48
|
-
|
|
49
|
-
<% if kill_switch_active? %>
|
|
50
|
-
<p style="margin-bottom: 1rem; color: #856404;">
|
|
51
|
-
<strong>Kill Switch is ACTIVE for <%= current_environment %> environment.</strong><br>
|
|
52
|
-
You must provide confirmation text to proceed.
|
|
53
|
-
</p>
|
|
54
|
-
<% end %>
|
|
55
|
-
|
|
56
|
-
<% form_id = "execute-capsule-form" %>
|
|
57
|
-
<%= form_with url: execute_sql_capsule_path(@capsule.name), method: :post, local: true, id: form_id do |f| %>
|
|
58
|
-
<div style="display: flex; gap: 1rem; align-items: flex-end;">
|
|
59
|
-
<div style="flex: 1;">
|
|
60
|
-
<button type="button" onclick="showKillSwitchModal('<%= form_id %>')"
|
|
61
|
-
style="width: 100%; padding: 0.75rem 1.5rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; font-weight: 600;">
|
|
62
|
-
Execute SQL Capsule
|
|
63
|
-
</button>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
<% end %>
|
|
67
|
-
|
|
68
|
-
<%= render 'pg_sql_triggers/shared/confirmation_modal',
|
|
69
|
-
operation: :execute_sql_capsule,
|
|
70
|
-
form_id: form_id,
|
|
71
|
-
title: 'Execute SQL Capsule',
|
|
72
|
-
message: "Are you sure you want to execute SQL capsule '#{@capsule.name}'? This will run the SQL statement directly against the database." %>
|
|
73
|
-
</div>
|
|
74
|
-
<% else %>
|
|
75
|
-
<div style="background: #f8d7da; border: 1px solid #f5c6cb; padding: 1rem; border-radius: 4px; color: #721c24;">
|
|
76
|
-
<strong>Insufficient Permissions:</strong> You need Admin role to execute SQL capsules.
|
|
77
|
-
</div>
|
|
78
|
-
<% end %>
|
|
79
|
-
|
|
80
|
-
<div style="margin-top: 2rem;">
|
|
81
|
-
<%= link_to "Create Another Capsule", new_sql_capsule_path,
|
|
82
|
-
class: "btn btn-primary",
|
|
83
|
-
style: "padding: 0.75rem 1.5rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; display: inline-block;" %>
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rails/generators"
|
|
4
|
-
require "rails/generators/migration"
|
|
5
|
-
require "active_support/core_ext/string/inflections"
|
|
6
|
-
|
|
7
|
-
module Trigger
|
|
8
|
-
module Generators
|
|
9
|
-
class MigrationGenerator < Rails::Generators::Base
|
|
10
|
-
include Rails::Generators::Migration
|
|
11
|
-
|
|
12
|
-
source_root File.expand_path("../../pg_sql_triggers/templates", __dir__)
|
|
13
|
-
|
|
14
|
-
argument :name, type: :string, desc: "Name of the trigger migration"
|
|
15
|
-
|
|
16
|
-
def self.next_migration_number(_dirname)
|
|
17
|
-
# Get the highest migration number from existing migrations
|
|
18
|
-
existing = if Rails.root.join("db/triggers").exist?
|
|
19
|
-
Rails.root.glob("db/triggers/*.rb")
|
|
20
|
-
.map { |f| File.basename(f, ".rb").split("_").first.to_i }
|
|
21
|
-
.reject(&:zero?)
|
|
22
|
-
.max || 0
|
|
23
|
-
else
|
|
24
|
-
0
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Generate next timestamp-based version
|
|
28
|
-
# Format: YYYYMMDDHHMMSS
|
|
29
|
-
now = Time.now.utc
|
|
30
|
-
base = now.strftime("%Y%m%d%H%M%S").to_i
|
|
31
|
-
|
|
32
|
-
# If we have existing migrations, ensure we're incrementing
|
|
33
|
-
base = existing + 1 if existing.positive? && base <= existing
|
|
34
|
-
|
|
35
|
-
base
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def create_trigger_migration
|
|
39
|
-
migration_template(
|
|
40
|
-
"trigger_migration.rb.erb",
|
|
41
|
-
"db/triggers/#{file_name}.rb"
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def file_name
|
|
48
|
-
"#{migration_number}_#{name.underscore}"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def class_name
|
|
52
|
-
name.camelize
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def migration_number
|
|
56
|
-
self.class.next_migration_number(nil)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module PgSqlTriggers
|
|
4
|
-
module Generator
|
|
5
|
-
class Form
|
|
6
|
-
include ActiveModel::Model
|
|
7
|
-
|
|
8
|
-
attr_accessor :trigger_name, :table_name, :function_name,
|
|
9
|
-
:version, :enabled, :condition, :timing,
|
|
10
|
-
:generate_function_stub, :events, :environments,
|
|
11
|
-
:function_body
|
|
12
|
-
|
|
13
|
-
validates :trigger_name, presence: true,
|
|
14
|
-
format: {
|
|
15
|
-
with: /\A[a-z0-9_]+\z/,
|
|
16
|
-
message: "must contain only lowercase letters, numbers, and underscores"
|
|
17
|
-
}
|
|
18
|
-
validates :table_name, presence: true
|
|
19
|
-
validates :function_name, presence: true,
|
|
20
|
-
format: {
|
|
21
|
-
with: /\A[a-z0-9_]+\z/,
|
|
22
|
-
message: "must contain only lowercase letters, numbers, and underscores"
|
|
23
|
-
}
|
|
24
|
-
validates :version, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
25
|
-
validates :function_body, presence: true
|
|
26
|
-
validates :timing, inclusion: { in: %w[before after], message: "must be 'before' or 'after'" }
|
|
27
|
-
validate :at_least_one_event
|
|
28
|
-
validate :function_name_matches_body
|
|
29
|
-
|
|
30
|
-
def initialize(attributes = {})
|
|
31
|
-
super
|
|
32
|
-
@version ||= 1
|
|
33
|
-
# Convert enabled to boolean (Rails checkboxes send "0" or "1" as strings)
|
|
34
|
-
# Default to true for UI-generated triggers
|
|
35
|
-
@enabled = case @enabled
|
|
36
|
-
when false, "0", 0 then false
|
|
37
|
-
else true # true, "1", 1, or nil - default to true
|
|
38
|
-
end
|
|
39
|
-
@generate_function_stub = true if @generate_function_stub.nil?
|
|
40
|
-
@events ||= []
|
|
41
|
-
@environments ||= []
|
|
42
|
-
@timing ||= "before" # Default to "before" if not specified
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def default_function_body
|
|
46
|
-
func_name = function_name.presence || "function_name"
|
|
47
|
-
<<~SQL.chomp
|
|
48
|
-
CREATE OR REPLACE FUNCTION #{func_name}()
|
|
49
|
-
RETURNS TRIGGER AS $$
|
|
50
|
-
BEGIN
|
|
51
|
-
-- Your trigger logic here
|
|
52
|
-
RETURN NEW;
|
|
53
|
-
END;
|
|
54
|
-
$$ LANGUAGE plpgsql;
|
|
55
|
-
SQL
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def at_least_one_event
|
|
61
|
-
return unless events.blank? || events.compact_blank.empty?
|
|
62
|
-
|
|
63
|
-
errors.add(:events, "must include at least one event")
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def function_name_matches_body
|
|
67
|
-
return if function_name.blank? || function_body.blank?
|
|
68
|
-
|
|
69
|
-
# Check if function_body contains the function_name in a CREATE FUNCTION statement
|
|
70
|
-
# Look for pattern: CREATE [OR REPLACE] FUNCTION function_name
|
|
71
|
-
function_pattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:[^(\s]+\.)?#{Regexp.escape(function_name)}\s*\(/i
|
|
72
|
-
return if function_body.match?(function_pattern)
|
|
73
|
-
|
|
74
|
-
expected_msg = "should define function '#{function_name}' " \
|
|
75
|
-
"(expected: CREATE [OR REPLACE] FUNCTION #{function_name}(...)"
|
|
76
|
-
errors.add(:function_body, expected_msg)
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|