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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +253 -1
  3. data/GEM_ANALYSIS.md +368 -0
  4. data/README.md +20 -23
  5. data/app/models/pg_sql_triggers/trigger_registry.rb +42 -6
  6. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
  7. data/app/views/pg_sql_triggers/dashboard/index.html.erb +1 -4
  8. data/app/views/pg_sql_triggers/tables/index.html.erb +1 -4
  9. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
  10. data/config/routes.rb +0 -14
  11. data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  12. data/docs/api-reference.md +44 -153
  13. data/docs/configuration.md +24 -3
  14. data/docs/getting-started.md +17 -16
  15. data/docs/usage-guide.md +38 -67
  16. data/docs/web-ui.md +3 -103
  17. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  18. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  19. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  20. data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
  21. data/lib/pg_sql_triggers/drift/detector.rb +51 -38
  22. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
  23. data/lib/pg_sql_triggers/engine.rb +14 -0
  24. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
  25. data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
  26. data/lib/pg_sql_triggers/migrator.rb +53 -6
  27. data/lib/pg_sql_triggers/registry/manager.rb +36 -11
  28. data/lib/pg_sql_triggers/registry/validator.rb +62 -5
  29. data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -275
  30. data/lib/pg_sql_triggers/sql.rb +0 -6
  31. data/lib/pg_sql_triggers/version.rb +1 -1
  32. data/lib/pg_sql_triggers.rb +4 -1
  33. data/pg_sql_triggers.gemspec +53 -0
  34. metadata +7 -13
  35. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  36. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  37. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  38. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  39. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  40. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  41. data/lib/generators/trigger/migration_generator.rb +0 -60
  42. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  43. data/lib/pg_sql_triggers/generator/service.rb +0 -339
  44. data/lib/pg_sql_triggers/generator.rb +0 -8
  45. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  46. 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