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,388 +0,0 @@
1
- <h2>Generate New Trigger</h2>
2
-
3
- <p style="color: #6c757d; margin-bottom: 2rem;">
4
- Create a new PostgreSQL trigger using the form-based wizard.
5
- Generated triggers are <strong>enabled by default</strong>.
6
- </p>
7
-
8
- <%= form_with model: @form, url: preview_generator_index_path, method: :post,
9
- scope: :pg_sql_triggers_generator_form,
10
- id: "trigger-generator-form",
11
- local: true,
12
- html: { style: "background: white; padding: 2rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);", onsubmit: "return validateForm();" } do |f| %>
13
-
14
- <!-- Section 1: Basic Information -->
15
- <fieldset style="border: 1px solid #dee2e6; padding: 1rem; margin-bottom: 2rem; border-radius: 4px;">
16
- <legend style="font-weight: 600; color: #495057; padding: 0 0.5rem;">Basic Information</legend>
17
-
18
- <div style="margin-bottom: 1rem;">
19
- <%= f.label :trigger_name, "Trigger Name *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
20
- <%= f.text_field :trigger_name,
21
- placeholder: "e.g., users_email_validation",
22
- required: true,
23
- style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
24
- <small style="color: #6c757d;">Lowercase, underscores only</small>
25
- <% if @form.errors[:trigger_name].any? %>
26
- <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:trigger_name].first %></div>
27
- <% end %>
28
- </div>
29
-
30
- <div style="margin-bottom: 1rem;">
31
- <%= f.label :table_name, "Table Name *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
32
- <div style="display: flex; gap: 0.5rem; align-items: start;">
33
- <%= f.select :table_name,
34
- options_for_select(@available_tables.map { |t| [t, t] }, @form.table_name),
35
- { include_blank: "Select a table..." },
36
- {
37
- required: true,
38
- id: "table-name-select",
39
- style: "flex: 1; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
40
- } %>
41
- <%= link_to "Browse Tables", tables_path, target: "_blank", class: "btn btn-primary", style: "padding: 0.5rem 1rem; white-space: nowrap; text-decoration: none;" %>
42
- </div>
43
- <div id="table-validation-message" style="margin-top: 0.25rem;"></div>
44
- <div id="table-triggers-info" style="margin-top: 0.5rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; display: none;">
45
- <strong style="color: #495057;">Existing Triggers:</strong>
46
- <div id="table-triggers-list" style="margin-top: 0.5rem;"></div>
47
- </div>
48
- <% if @form.errors[:table_name].any? %>
49
- <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:table_name].first %></div>
50
- <% end %>
51
- </div>
52
-
53
- <div style="margin-bottom: 1rem;">
54
- <%= f.label :function_name, "Function Name *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
55
- <%= f.text_field :function_name,
56
- placeholder: "e.g., validate_user_email",
57
- required: true,
58
- id: "function-name-input",
59
- style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
60
- <small style="color: #6c757d;">The PL/pgSQL function to invoke</small>
61
- <% if @form.errors[:function_name].any? %>
62
- <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:function_name].first %></div>
63
- <% end %>
64
- </div>
65
-
66
- <div style="margin-bottom: 1rem;">
67
- <%= f.label :function_body, "Function Body *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
68
- <%
69
- default_function_body = @form.function_body.presence || @form.default_function_body
70
- %>
71
- <%= f.text_area :function_body,
72
- value: default_function_body,
73
- placeholder: "CREATE OR REPLACE FUNCTION #{@form.function_name || 'function_name'}()\nRETURNS TRIGGER AS $$\nBEGIN\n -- Your trigger logic here\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;",
74
- required: true,
75
- id: "function-body-textarea",
76
- rows: 12,
77
- style: "width: 100%; padding: 0.5rem; 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; resize: vertical;" %>
78
- <small style="color: #6c757d;">
79
- Provide the complete PL/pgSQL function definition (BEGIN...END block).
80
- </small>
81
- <% if @form.errors[:function_body].any? %>
82
- <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:function_body].first %></div>
83
- <% end %>
84
- </div>
85
- </fieldset>
86
-
87
- <!-- Section 2: Trigger Events -->
88
- <fieldset style="border: 1px solid #dee2e6; padding: 1rem; margin-bottom: 2rem; border-radius: 4px;">
89
- <legend style="font-weight: 600; color: #495057; padding: 0 0.5rem;">Trigger Events *</legend>
90
-
91
- <div style="margin-bottom: 1rem;">
92
- <%= f.label :timing, "Trigger Timing *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
93
- <%= f.select :timing,
94
- options_for_select([["Before", "before"], ["After", "after"]], @form.timing || "before"),
95
- {},
96
- {
97
- required: true,
98
- style: "width: 200px; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
99
- } %>
100
- <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
101
- When the trigger should fire: BEFORE (before constraint checks) or AFTER (after constraint checks)
102
- </small>
103
- <% if @form.errors[:timing].any? %>
104
- <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:timing].first %></div>
105
- <% end %>
106
- </div>
107
-
108
- <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
109
- <% %w[insert update delete truncate].each do |event| %>
110
- <label style="display: flex; align-items: center; cursor: pointer;">
111
- <%= check_box_tag "pg_sql_triggers_generator_form[events][]", event, Array(@form.events).include?(event), id: "events_#{event}" %>
112
- <span style="margin-left: 0.25rem; text-transform: uppercase;"><%= event %></span>
113
- </label>
114
- <% end %>
115
- </div>
116
- <small style="color: #6c757d; display: block; margin-top: 0.5rem;">
117
- Select one or more events that will trigger the function
118
- </small>
119
- <% if @form.errors[:events].any? %>
120
- <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:events].first %></div>
121
- <% end %>
122
- </fieldset>
123
-
124
- <!-- Section 3: Configuration -->
125
- <fieldset style="border: 1px solid #dee2e6; padding: 1rem; margin-bottom: 2rem; border-radius: 4px;">
126
- <legend style="font-weight: 600; color: #495057; padding: 0 0.5rem;">Configuration</legend>
127
-
128
- <div style="margin-bottom: 1rem;">
129
- <%= f.label :version, "Version", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
130
- <%= f.number_field :version, value: @form.version || 1, min: 1,
131
- style: "width: 100px; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
132
- <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
133
- Default: 1 (increment for future changes)
134
- </small>
135
- </div>
136
-
137
- <div style="margin-bottom: 1rem;">
138
- <%= f.label :environments, "Target Environments", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
139
- <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
140
- <% %w[development test staging production].each do |env| %>
141
- <label style="display: flex; align-items: center; cursor: pointer;">
142
- <%= check_box_tag "pg_sql_triggers_generator_form[environments][]", env, Array(@form.environments).include?(env), id: "environments_#{env}" %>
143
- <span style="margin-left: 0.25rem;"><%= env.titleize %></span>
144
- </label>
145
- <% end %>
146
- </div>
147
- <small style="color: #6c757d; display: block; margin-top: 0.5rem;">
148
- Leave empty to apply to all environments
149
- </small>
150
- </div>
151
-
152
- <div style="margin-bottom: 1rem;">
153
- <%= f.label :condition, "WHEN Condition (Optional)", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
154
- <%= f.text_area :condition,
155
- placeholder: "e.g., NEW.email IS NOT NULL",
156
- rows: 2,
157
- style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: monospace;" %>
158
- <small style="color: #6c757d;">
159
- Optional SQL condition for trigger firing
160
- </small>
161
- </div>
162
-
163
- <div style="margin-bottom: 1rem;">
164
- <label style="display: flex; align-items: center; cursor: pointer;">
165
- <%= f.check_box :enabled, checked: @form.enabled %>
166
- <span style="margin-left: 0.25rem; font-weight: 500;">Enable trigger after creation</span>
167
- </label>
168
- <small style="color: #6c757d; display: block; margin-left: 1.5rem;">
169
- Trigger will be enabled by default. Uncheck to create disabled.
170
- </small>
171
- </div>
172
-
173
- <div style="margin-bottom: 1rem;">
174
- <label style="display: flex; align-items: center; cursor: pointer;">
175
- <%= f.check_box :generate_function_stub, checked: @form.generate_function_stub %>
176
- <span style="margin-left: 0.25rem; font-weight: 500;">Generate PL/pgSQL function stub</span>
177
- </label>
178
- <small style="color: #6c757d; display: block; margin-left: 1.5rem;">
179
- Creates a template function file you can customize
180
- </small>
181
- </div>
182
- </fieldset>
183
-
184
- <!-- Actions -->
185
- <div style="display: flex; gap: 1rem;">
186
- <%= f.submit "Preview Generated Code", class: "btn btn-primary" %>
187
- <%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none;" %>
188
- </div>
189
- <% end %>
190
-
191
- <script>
192
- // Client-side form validation
193
- function validateForm() {
194
- const form = document.getElementById('trigger-generator-form');
195
- if (!form) return true;
196
-
197
- let isValid = true;
198
- const errors = [];
199
-
200
- // Validate trigger name
201
- const triggerName = form.querySelector('[name="pg_sql_triggers_generator_form[trigger_name]"]')?.value?.trim();
202
- if (!triggerName) {
203
- errors.push('Trigger name is required');
204
- isValid = false;
205
- } else if (!/^[a-z0-9_]+$/.test(triggerName)) {
206
- errors.push('Trigger name must contain only lowercase letters, numbers, and underscores');
207
- isValid = false;
208
- }
209
-
210
- // Validate table name
211
- const tableName = form.querySelector('[name="pg_sql_triggers_generator_form[table_name]"]')?.value?.trim();
212
- if (!tableName) {
213
- errors.push('Table name is required');
214
- isValid = false;
215
- }
216
-
217
- // Validate function name
218
- const functionName = form.querySelector('[name="pg_sql_triggers_generator_form[function_name]"]')?.value?.trim();
219
- if (!functionName) {
220
- errors.push('Function name is required');
221
- isValid = false;
222
- } else if (!/^[a-z0-9_]+$/.test(functionName)) {
223
- errors.push('Function name must contain only lowercase letters, numbers, and underscores');
224
- isValid = false;
225
- }
226
-
227
- // Validate function body
228
- const functionBody = form.querySelector('[name="pg_sql_triggers_generator_form[function_body]"]')?.value?.trim();
229
- if (!functionBody) {
230
- errors.push('Function body is required');
231
- isValid = false;
232
- } else if (functionName && !functionBody.match(new RegExp('CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:[^\\s(]+\\.)?' + functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\(', 'i'))) {
233
- errors.push('Function body should define function \'' + functionName + '\'');
234
- isValid = false;
235
- }
236
-
237
- // Validate at least one event is selected
238
- const eventCheckboxes = form.querySelectorAll('[name="pg_sql_triggers_generator_form[events][]"]:checked');
239
- if (eventCheckboxes.length === 0) {
240
- errors.push('At least one event must be selected');
241
- isValid = false;
242
- }
243
-
244
- // Validate version
245
- const version = form.querySelector('[name="pg_sql_triggers_generator_form[version]"]')?.value?.trim();
246
- if (!version) {
247
- errors.push('Version is required');
248
- isValid = false;
249
- } else {
250
- const versionNum = parseInt(version, 10);
251
- if (isNaN(versionNum) || versionNum < 1) {
252
- errors.push('Version must be a positive integer');
253
- isValid = false;
254
- }
255
- }
256
-
257
- // Display errors
258
- let errorDiv = document.getElementById('form-validation-errors');
259
- if (!errorDiv) {
260
- errorDiv = document.createElement('div');
261
- errorDiv.id = 'form-validation-errors';
262
- errorDiv.style.cssText = 'margin-bottom: 1rem; padding: 1rem; background: #f8d7da; border-left: 4px solid #dc3545; border-radius: 4px;';
263
- form.insertBefore(errorDiv, form.firstChild);
264
- }
265
-
266
- if (!isValid) {
267
- errorDiv.innerHTML = '<strong style="color: #721c24;">Please fix the following errors:</strong><ul style="margin: 0.5rem 0 0 1.5rem; padding: 0; color: #721c24;"><li>' + errors.join('</li><li>') + '</li></ul>';
268
- errorDiv.style.display = 'block';
269
- // Scroll to top of form
270
- errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
271
- } else {
272
- errorDiv.style.display = 'none';
273
- }
274
-
275
- return isValid;
276
- }
277
-
278
- // Generate function body template
279
- function generateFunctionBody(functionName) {
280
- return 'CREATE OR REPLACE FUNCTION ' + (functionName || 'function_name') + '()\n' +
281
- 'RETURNS TRIGGER AS $$\n' +
282
- 'BEGIN\n' +
283
- ' -- Your trigger logic here\n' +
284
- ' RETURN NEW;\n' +
285
- 'END;\n' +
286
- '$$ LANGUAGE plpgsql;';
287
- }
288
-
289
- // Check if textarea value matches the template pattern (so we know it's still the default)
290
- function isTemplateValue(value) {
291
- if (!value || value.trim() === '') return true;
292
- // Check if it matches the template pattern
293
- const templatePattern = /^CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+\w+\(\)\s+RETURNS\s+TRIGGER/i;
294
- return templatePattern.test(value.trim());
295
- }
296
-
297
- // Update function body value and placeholder when function name changes
298
- document.getElementById('function-name-input')?.addEventListener('input', function(e) {
299
- const functionName = e.target.value || 'function_name';
300
- const functionBodyTextarea = document.getElementById('function-body-textarea');
301
- if (functionBodyTextarea) {
302
- const newTemplate = generateFunctionBody(functionName);
303
-
304
- // Update placeholder
305
- functionBodyTextarea.placeholder = newTemplate;
306
-
307
- // Update value only if textarea is empty or matches template pattern
308
- if (!functionBodyTextarea.value || isTemplateValue(functionBodyTextarea.value)) {
309
- functionBodyTextarea.value = newTemplate;
310
- }
311
- }
312
- });
313
-
314
- // Client-side validation for table selection
315
- document.getElementById('table-name-select')?.addEventListener('change', function(e) {
316
- const tableName = e.target.value;
317
- const messageDiv = document.getElementById('table-validation-message');
318
- const infoDiv = document.getElementById('table-triggers-info');
319
- const triggersList = document.getElementById('table-triggers-list');
320
-
321
- if (!tableName) {
322
- messageDiv.innerHTML = '';
323
- if (infoDiv) infoDiv.style.display = 'none';
324
- return;
325
- }
326
-
327
- messageDiv.innerHTML = '<span style="color: #6c757d;">Validating...</span>';
328
-
329
- fetch('<%= validate_table_generator_index_path %>', {
330
- method: 'POST',
331
- headers: {
332
- 'Content-Type': 'application/json',
333
- 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
334
- },
335
- body: JSON.stringify({ table_name: tableName })
336
- })
337
- .then(response => response.json())
338
- .then(data => {
339
- if (data.valid) {
340
- messageDiv.innerHTML = '<span style="color: #28a745;">✓ Table exists (' + data.column_count + ' columns)</span>';
341
-
342
- // Try to fetch existing triggers for this table
343
- if (infoDiv && triggersList) {
344
- fetch('<%= tables_path %>/' + encodeURIComponent(tableName) + '.json', {
345
- headers: {
346
- 'Accept': 'application/json',
347
- 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
348
- }
349
- })
350
- .then(response => response.json())
351
- .then(data => {
352
- if (data.registry_triggers && data.registry_triggers.length > 0) {
353
- let triggersHtml = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
354
- data.registry_triggers.forEach(trigger => {
355
- triggersHtml += '<div style="padding: 0.5rem; background: white; border-radius: 4px; border-left: 3px solid #007bff;">';
356
- triggersHtml += '<strong>' + trigger.trigger_name + '</strong> ';
357
- triggersHtml += '<span class="badge ' + (trigger.enabled ? 'badge-success' : 'badge-danger') + '" style="font-size: 0.75rem;">' + (trigger.enabled ? 'Enabled' : 'Disabled') + '</span>';
358
- if (trigger.function_name) {
359
- triggersHtml += '<br><small style="color: #6c757d;">Function: <code>' + trigger.function_name + '</code></small>';
360
- }
361
- triggersHtml += '</div>';
362
- });
363
- triggersHtml += '</div>';
364
- triggersHtml += '<div style="margin-top: 0.5rem;"><a href="<%= tables_path %>/' + encodeURIComponent(tableName) + '" target="_blank" style="color: #007bff; text-decoration: none; font-size: 0.875rem;">View all table details →</a></div>';
365
- triggersList.innerHTML = triggersHtml;
366
- infoDiv.style.display = 'block';
367
- } else {
368
- infoDiv.style.display = 'none';
369
- }
370
- })
371
- .catch(error => {
372
- console.error('Failed to fetch triggers:', error);
373
- // Fallback: show link to view table details
374
- triggersList.innerHTML = '<a href="<%= tables_path %>/' + encodeURIComponent(tableName) + '" target="_blank" style="color: #007bff; text-decoration: none;">View table details →</a>';
375
- infoDiv.style.display = 'block';
376
- });
377
- }
378
- } else {
379
- messageDiv.innerHTML = '<span style="color: #dc3545;">✗ Table not found in database</span>';
380
- if (infoDiv) infoDiv.style.display = 'none';
381
- }
382
- })
383
- .catch(error => {
384
- messageDiv.innerHTML = '<span style="color: #856404;">⚠ Validation unavailable</span>';
385
- if (infoDiv) infoDiv.style.display = 'none';
386
- });
387
- });
388
- </script>