pg_sql_triggers 1.0.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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +120 -0
  4. data/CHANGELOG.md +52 -0
  5. data/Goal.md +294 -0
  6. data/LICENSE +21 -0
  7. data/README.md +294 -0
  8. data/RELEASE.md +270 -0
  9. data/Rakefile +16 -0
  10. data/app/assets/javascripts/pg_sql_triggers/application.js +5 -0
  11. data/app/assets/stylesheets/pg_sql_triggers/application.css +179 -0
  12. data/app/controllers/pg_sql_triggers/application_controller.rb +35 -0
  13. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +42 -0
  14. data/app/controllers/pg_sql_triggers/generator_controller.rb +145 -0
  15. data/app/controllers/pg_sql_triggers/migrations_controller.rb +84 -0
  16. data/app/controllers/pg_sql_triggers/tables_controller.rb +44 -0
  17. data/app/models/pg_sql_triggers/application_record.rb +7 -0
  18. data/app/models/pg_sql_triggers/trigger_registry.rb +93 -0
  19. data/app/views/layouts/pg_sql_triggers/application.html.erb +72 -0
  20. data/app/views/pg_sql_triggers/dashboard/index.html.erb +225 -0
  21. data/app/views/pg_sql_triggers/generator/new.html.erb +370 -0
  22. data/app/views/pg_sql_triggers/generator/preview.html.erb +77 -0
  23. data/app/views/pg_sql_triggers/tables/index.html.erb +105 -0
  24. data/app/views/pg_sql_triggers/tables/show.html.erb +126 -0
  25. data/config/routes.rb +35 -0
  26. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +29 -0
  27. data/lib/generators/pg_sql_triggers/install_generator.rb +36 -0
  28. data/lib/generators/pg_sql_triggers/templates/README +36 -0
  29. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +36 -0
  30. data/lib/generators/pg_sql_triggers/templates/initializer.rb +27 -0
  31. data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +32 -0
  32. data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +60 -0
  33. data/lib/generators/trigger/migration_generator.rb +60 -0
  34. data/lib/pg_sql_triggers/database_introspection.rb +251 -0
  35. data/lib/pg_sql_triggers/drift.rb +24 -0
  36. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +67 -0
  37. data/lib/pg_sql_triggers/dsl.rb +15 -0
  38. data/lib/pg_sql_triggers/engine.rb +29 -0
  39. data/lib/pg_sql_triggers/generator/form.rb +78 -0
  40. data/lib/pg_sql_triggers/generator/service.rb +251 -0
  41. data/lib/pg_sql_triggers/generator.rb +8 -0
  42. data/lib/pg_sql_triggers/migration.rb +15 -0
  43. data/lib/pg_sql_triggers/migrator.rb +237 -0
  44. data/lib/pg_sql_triggers/permissions/checker.rb +33 -0
  45. data/lib/pg_sql_triggers/permissions.rb +35 -0
  46. data/lib/pg_sql_triggers/registry/manager.rb +47 -0
  47. data/lib/pg_sql_triggers/registry/validator.rb +15 -0
  48. data/lib/pg_sql_triggers/registry.rb +36 -0
  49. data/lib/pg_sql_triggers/sql.rb +21 -0
  50. data/lib/pg_sql_triggers/testing/dry_run.rb +74 -0
  51. data/lib/pg_sql_triggers/testing/function_tester.rb +118 -0
  52. data/lib/pg_sql_triggers/testing/safe_executor.rb +66 -0
  53. data/lib/pg_sql_triggers/testing/syntax_validator.rb +124 -0
  54. data/lib/pg_sql_triggers/testing.rb +10 -0
  55. data/lib/pg_sql_triggers/version.rb +15 -0
  56. data/lib/pg_sql_triggers.rb +41 -0
  57. data/lib/tasks/trigger_migrations.rake +254 -0
  58. data/sig/pg_sql_triggers.rbs +4 -0
  59. metadata +260 -0
@@ -0,0 +1,225 @@
1
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
2
+ <h2 style="margin: 0;">Trigger Dashboard</h2>
3
+ <div style="display: flex; gap: 1rem;">
4
+ <%= link_to "View Tables", tables_path, class: "btn btn-primary", style: "font-size: 1rem; padding: 0.75rem 1.5rem; text-decoration: none;" %>
5
+ <%= link_to "Generate New Trigger", new_generator_path,
6
+ class: "btn btn-success",
7
+ style: "font-size: 1rem; padding: 0.75rem 1.5rem; text-decoration: none;" %>
8
+ </div>
9
+ </div>
10
+
11
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
12
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
13
+ <div style="font-size: 2rem; font-weight: 600; color: #007bff;"><%= @stats[:total] %></div>
14
+ <div style="color: #6c757d;">Total Triggers</div>
15
+ </div>
16
+
17
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
18
+ <div style="font-size: 2rem; font-weight: 600; color: #28a745;"><%= @stats[:enabled] %></div>
19
+ <div style="color: #6c757d;">Enabled</div>
20
+ </div>
21
+
22
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
23
+ <div style="font-size: 2rem; font-weight: 600; color: #6c757d;"><%= @stats[:disabled] %></div>
24
+ <div style="color: #6c757d;">Disabled</div>
25
+ </div>
26
+
27
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
28
+ <div style="font-size: 2rem; font-weight: 600; color: #ffc107;"><%= @stats[:drifted] %></div>
29
+ <div style="color: #6c757d;">Drifted</div>
30
+ </div>
31
+ </div>
32
+
33
+ <% if @triggers.any? %>
34
+ <h3>Recent Triggers</h3>
35
+ <table>
36
+ <thead>
37
+ <tr>
38
+ <th>Trigger Name</th>
39
+ <th>Table</th>
40
+ <th>Version</th>
41
+ <th>Status</th>
42
+ <th>Source</th>
43
+ <th>Actions</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ <% @triggers.limit(10).each do |trigger| %>
48
+ <tr>
49
+ <td><strong><%= trigger.trigger_name %></strong></td>
50
+ <td><%= trigger.table_name %></td>
51
+ <td><%= trigger.version %></td>
52
+ <td>
53
+ <% if trigger.enabled %>
54
+ <span class="badge badge-success">Enabled</span>
55
+ <% else %>
56
+ <span class="badge badge-danger">Disabled</span>
57
+ <% end %>
58
+ </td>
59
+ <td><span class="badge badge-info"><%= trigger.source %></span></td>
60
+ <td>—</td>
61
+ </tr>
62
+ <% end %>
63
+ </tbody>
64
+ </table>
65
+ <% else %>
66
+ <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1.5rem; border-radius: 4px;">
67
+ <h3 style="margin-top: 0;">No triggers yet</h3>
68
+ <p style="margin-bottom: 1rem;">Get started by generating your first trigger using the form-based wizard.</p>
69
+ <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
70
+ </div>
71
+ <% end %>
72
+
73
+ <!-- Migration Management Section -->
74
+ <div style="margin-top: 3rem;">
75
+ <h3>Trigger Migrations</h3>
76
+
77
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
78
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
79
+ <div style="padding: 1rem; background: #f8f9fa; border-radius: 4px;">
80
+ <div style="font-size: 1.5rem; font-weight: 600; color: #007bff;"><%= @total_migrations || 0 %></div>
81
+ <div style="color: #6c757d;">Total Migrations</div>
82
+ </div>
83
+ <div style="padding: 1rem; background: #f8f9fa; border-radius: 4px;">
84
+ <div style="font-size: 1.5rem; font-weight: 600; color: #28a745;"><%= @migration_status.count { |m| m[:status] == "up" } %></div>
85
+ <div style="color: #6c757d;">Applied</div>
86
+ </div>
87
+ <div style="padding: 1rem; background: #f8f9fa; border-radius: 4px;">
88
+ <div style="font-size: 1.5rem; font-weight: 600; color: #ffc107;"><%= @pending_migrations.count %></div>
89
+ <div style="color: #6c757d;">Pending</div>
90
+ </div>
91
+ <div style="padding: 1rem; background: #f8f9fa; border-radius: 4px;">
92
+ <div style="font-size: 1.5rem; font-weight: 600; color: #6c757d;"><%= @current_migration_version %></div>
93
+ <div style="color: #6c757d;">Current Version</div>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Migration Action Buttons -->
98
+ <div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;">
99
+ <% if @pending_migrations.any? %>
100
+ <%= form_with url: up_migrations_path, method: :post, local: true, style: "margin: 0;" do |f| %>
101
+ <%= f.submit "Apply All Pending Migrations",
102
+ style: "padding: 0.75rem 1.5rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;",
103
+ data: { confirm: "Are you sure you want to apply #{@pending_migrations.count} pending migration(s)?" } %>
104
+ <% end %>
105
+ <% end %>
106
+
107
+ <% if @current_migration_version > 0 %>
108
+ <%= form_with url: down_migrations_path, method: :post, local: true, style: "margin: 0;" do |f| %>
109
+ <%= f.submit "Rollback Last Migration",
110
+ style: "padding: 0.75rem 1.5rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;",
111
+ data: { confirm: "Are you sure you want to rollback the last migration?" } %>
112
+ <% end %>
113
+
114
+ <%= form_with url: redo_migrations_path, method: :post, local: true, style: "margin: 0;" do |f| %>
115
+ <%= f.submit "Redo Last Migration",
116
+ style: "padding: 0.75rem 1.5rem; background: #ffc107; color: #212529; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 600;",
117
+ data: { confirm: "Are you sure you want to redo the last migration?" } %>
118
+ <% end %>
119
+ <% end %>
120
+ </div>
121
+
122
+ <% if @pending_migrations.any? %>
123
+ <div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; border-radius: 4px; margin-bottom: 1.5rem;">
124
+ <strong style="color: #856404;">Pending Migrations:</strong>
125
+ <ul style="margin: 0.5rem 0 0 1.5rem; color: #856404;">
126
+ <% @pending_migrations.each do |migration| %>
127
+ <li><code><%= migration.filename %></code></li>
128
+ <% end %>
129
+ </ul>
130
+ </div>
131
+ <% end %>
132
+
133
+ <% if @total_migrations > 0 %>
134
+ <h4 style="margin-top: 0;">Migration Status</h4>
135
+ <div style="margin-bottom: 1rem; color: #6c757d; font-size: 0.875rem;">
136
+ Showing <%= (@page - 1) * @per_page + 1 %>-<%= [@page * @per_page, @total_migrations].min %> of <%= @total_migrations %> migrations
137
+ </div>
138
+ <table>
139
+ <thead>
140
+ <tr>
141
+ <th>Version</th>
142
+ <th>Name</th>
143
+ <th>Status</th>
144
+ <th>Filename</th>
145
+ <th>Actions</th>
146
+ </tr>
147
+ </thead>
148
+ <tbody>
149
+ <% @migration_status.each do |migration| %>
150
+ <tr>
151
+ <td><strong><%= migration[:version] %></strong></td>
152
+ <td><%= migration[:name] %></td>
153
+ <td>
154
+ <% if migration[:status] == "up" %>
155
+ <span class="badge badge-success">Applied</span>
156
+ <% else %>
157
+ <span class="badge badge-warning">Pending</span>
158
+ <% end %>
159
+ </td>
160
+ <td><code style="font-size: 0.875rem;"><%= migration[:filename] %></code></td>
161
+ <td>
162
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
163
+ <% if migration[:status] == "down" %>
164
+ <%= form_with url: up_migrations_path, method: :post, local: true, style: "margin: 0;" do |f| %>
165
+ <%= f.hidden_field :version, value: migration[:version] %>
166
+ <%= f.submit "Up",
167
+ style: "padding: 0.25rem 0.75rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;",
168
+ data: { confirm: "Apply migration #{migration[:version]}?" } %>
169
+ <% end %>
170
+ <% else %>
171
+ <%= form_with url: down_migrations_path, method: :post, local: true, style: "margin: 0;" do |f| %>
172
+ <%= f.hidden_field :version, value: migration[:version] %>
173
+ <%= f.submit "Down",
174
+ style: "padding: 0.25rem 0.75rem; background: #dc3545; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;",
175
+ data: { confirm: "Rollback to version #{migration[:version]}?" } %>
176
+ <% end %>
177
+ <%= form_with url: redo_migrations_path, method: :post, local: true, style: "margin: 0;" do |f| %>
178
+ <%= f.hidden_field :version, value: migration[:version] %>
179
+ <%= f.submit "Redo",
180
+ style: "padding: 0.25rem 0.75rem; background: #ffc107; color: #212529; border: none; border-radius: 3px; cursor: pointer; font-size: 0.75rem;",
181
+ data: { confirm: "Redo migration #{migration[:version]}?" } %>
182
+ <% end %>
183
+ <% end %>
184
+ </div>
185
+ </td>
186
+ </tr>
187
+ <% end %>
188
+ </tbody>
189
+ </table>
190
+
191
+ <!-- Pagination Controls -->
192
+ <% if @total_pages > 1 %>
193
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #dee2e6;">
194
+ <div>
195
+ <% if @page > 1 %>
196
+ <%= link_to "← Previous", dashboard_path(page: @page - 1, per_page: @per_page),
197
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 0.5rem;" %>
198
+ <% end %>
199
+ <% if @page < @total_pages %>
200
+ <%= link_to "Next →", dashboard_path(page: @page + 1, per_page: @per_page),
201
+ style: "padding: 0.5rem 1rem; background: #007bff; color: white; text-decoration: none; border-radius: 4px;" %>
202
+ <% end %>
203
+ </div>
204
+ <div style="color: #6c757d; font-size: 0.875rem;">
205
+ Page <%= @page %> of <%= @total_pages %>
206
+ </div>
207
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
208
+ <label style="color: #6c757d; font-size: 0.875rem;">Per page:</label>
209
+ <select onchange="window.location.href='<%= dashboard_path %>?page=1&per_page=' + this.value"
210
+ style="padding: 0.25rem 0.5rem; border: 1px solid #ced4da; border-radius: 4px;">
211
+ <option value="10" <%= 'selected' if @per_page == 10 %>>10</option>
212
+ <option value="20" <%= 'selected' if @per_page == 20 %>>20</option>
213
+ <option value="50" <%= 'selected' if @per_page == 50 %>>50</option>
214
+ <option value="100" <%= 'selected' if @per_page == 100 %>>100</option>
215
+ </select>
216
+ </div>
217
+ </div>
218
+ <% end %>
219
+ <% else %>
220
+ <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; border-radius: 4px;">
221
+ <p style="margin: 0; color: #0c5460;">No trigger migrations found. Migrations should be placed in <code>db/triggers/</code>.</p>
222
+ </div>
223
+ <% end %>
224
+ </div>
225
+ </div>
@@ -0,0 +1,370 @@
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
+ 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| %>
12
+
13
+ <!-- Section 1: Basic Information -->
14
+ <fieldset style="border: 1px solid #dee2e6; padding: 1rem; margin-bottom: 2rem; border-radius: 4px;">
15
+ <legend style="font-weight: 600; color: #495057; padding: 0 0.5rem;">Basic Information</legend>
16
+
17
+ <div style="margin-bottom: 1rem;">
18
+ <%= f.label :trigger_name, "Trigger Name *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
19
+ <%= f.text_field :trigger_name,
20
+ placeholder: "e.g., users_email_validation",
21
+ required: true,
22
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
23
+ <small style="color: #6c757d;">Lowercase, underscores only</small>
24
+ <% if @form.errors[:trigger_name].any? %>
25
+ <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:trigger_name].first %></div>
26
+ <% end %>
27
+ </div>
28
+
29
+ <div style="margin-bottom: 1rem;">
30
+ <%= f.label :table_name, "Table Name *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
31
+ <div style="display: flex; gap: 0.5rem; align-items: start;">
32
+ <%= f.select :table_name,
33
+ options_for_select(@available_tables.map { |t| [t, t] }, @form.table_name),
34
+ { include_blank: "Select a table..." },
35
+ {
36
+ required: true,
37
+ id: "table-name-select",
38
+ style: "flex: 1; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;"
39
+ } %>
40
+ <%= link_to "Browse Tables", tables_path, target: "_blank", class: "btn btn-primary", style: "padding: 0.5rem 1rem; white-space: nowrap; text-decoration: none;" %>
41
+ </div>
42
+ <div id="table-validation-message" style="margin-top: 0.25rem;"></div>
43
+ <div id="table-triggers-info" style="margin-top: 0.5rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px; display: none;">
44
+ <strong style="color: #495057;">Existing Triggers:</strong>
45
+ <div id="table-triggers-list" style="margin-top: 0.5rem;"></div>
46
+ </div>
47
+ <% if @form.errors[:table_name].any? %>
48
+ <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:table_name].first %></div>
49
+ <% end %>
50
+ </div>
51
+
52
+ <div style="margin-bottom: 1rem;">
53
+ <%= f.label :function_name, "Function Name *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
54
+ <%= f.text_field :function_name,
55
+ placeholder: "e.g., validate_user_email",
56
+ required: true,
57
+ id: "function-name-input",
58
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
59
+ <small style="color: #6c757d;">The PL/pgSQL function to invoke</small>
60
+ <% if @form.errors[:function_name].any? %>
61
+ <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:function_name].first %></div>
62
+ <% end %>
63
+ </div>
64
+
65
+ <div style="margin-bottom: 1rem;">
66
+ <%= f.label :function_body, "Function Body *", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
67
+ <%
68
+ default_function_body = @form.function_body.presence || @form.default_function_body
69
+ %>
70
+ <%= f.text_area :function_body,
71
+ value: default_function_body,
72
+ 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;",
73
+ required: true,
74
+ id: "function-body-textarea",
75
+ rows: 12,
76
+ 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;" %>
77
+ <small style="color: #6c757d;">
78
+ Provide the complete PL/pgSQL function definition (BEGIN...END block).
79
+ </small>
80
+ <% if @form.errors[:function_body].any? %>
81
+ <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:function_body].first %></div>
82
+ <% end %>
83
+ </div>
84
+ </fieldset>
85
+
86
+ <!-- Section 2: Trigger Events -->
87
+ <fieldset style="border: 1px solid #dee2e6; padding: 1rem; margin-bottom: 2rem; border-radius: 4px;">
88
+ <legend style="font-weight: 600; color: #495057; padding: 0 0.5rem;">Trigger Events *</legend>
89
+
90
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
91
+ <% %w[insert update delete truncate].each do |event| %>
92
+ <label style="display: flex; align-items: center; cursor: pointer;">
93
+ <%= check_box_tag "pg_sql_triggers_generator_form[events][]", event, Array(@form.events).include?(event), id: "events_#{event}" %>
94
+ <span style="margin-left: 0.25rem; text-transform: uppercase;"><%= event %></span>
95
+ </label>
96
+ <% end %>
97
+ </div>
98
+ <small style="color: #6c757d; display: block; margin-top: 0.5rem;">
99
+ Select one or more events that will trigger the function
100
+ </small>
101
+ <% if @form.errors[:events].any? %>
102
+ <div style="color: #dc3545; margin-top: 0.25rem;"><%= @form.errors[:events].first %></div>
103
+ <% end %>
104
+ </fieldset>
105
+
106
+ <!-- Section 3: Configuration -->
107
+ <fieldset style="border: 1px solid #dee2e6; padding: 1rem; margin-bottom: 2rem; border-radius: 4px;">
108
+ <legend style="font-weight: 600; color: #495057; padding: 0 0.5rem;">Configuration</legend>
109
+
110
+ <div style="margin-bottom: 1rem;">
111
+ <%= f.label :version, "Version", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
112
+ <%= f.number_field :version, value: @form.version || 1, min: 1,
113
+ style: "width: 100px; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px;" %>
114
+ <small style="color: #6c757d; display: block; margin-top: 0.25rem;">
115
+ Default: 1 (increment for future changes)
116
+ </small>
117
+ </div>
118
+
119
+ <div style="margin-bottom: 1rem;">
120
+ <%= f.label :environments, "Target Environments", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
121
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
122
+ <% %w[development test staging production].each do |env| %>
123
+ <label style="display: flex; align-items: center; cursor: pointer;">
124
+ <%= check_box_tag "pg_sql_triggers_generator_form[environments][]", env, Array(@form.environments).include?(env), id: "environments_#{env}" %>
125
+ <span style="margin-left: 0.25rem;"><%= env.titleize %></span>
126
+ </label>
127
+ <% end %>
128
+ </div>
129
+ <small style="color: #6c757d; display: block; margin-top: 0.5rem;">
130
+ Leave empty to apply to all environments
131
+ </small>
132
+ </div>
133
+
134
+ <div style="margin-bottom: 1rem;">
135
+ <%= f.label :condition, "WHEN Condition (Optional)", style: "display: block; font-weight: 500; margin-bottom: 0.25rem;" %>
136
+ <%= f.text_area :condition,
137
+ placeholder: "e.g., NEW.email IS NOT NULL",
138
+ rows: 2,
139
+ style: "width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; font-family: monospace;" %>
140
+ <small style="color: #6c757d;">
141
+ Optional SQL condition for trigger firing
142
+ </small>
143
+ </div>
144
+
145
+ <div style="margin-bottom: 1rem;">
146
+ <label style="display: flex; align-items: center; cursor: pointer;">
147
+ <%= f.check_box :enabled, checked: @form.enabled %>
148
+ <span style="margin-left: 0.25rem; font-weight: 500;">Enable trigger after creation</span>
149
+ </label>
150
+ <small style="color: #6c757d; display: block; margin-left: 1.5rem;">
151
+ Trigger will be enabled by default. Uncheck to create disabled.
152
+ </small>
153
+ </div>
154
+
155
+ <div style="margin-bottom: 1rem;">
156
+ <label style="display: flex; align-items: center; cursor: pointer;">
157
+ <%= f.check_box :generate_function_stub, checked: @form.generate_function_stub %>
158
+ <span style="margin-left: 0.25rem; font-weight: 500;">Generate PL/pgSQL function stub</span>
159
+ </label>
160
+ <small style="color: #6c757d; display: block; margin-left: 1.5rem;">
161
+ Creates a template function file you can customize
162
+ </small>
163
+ </div>
164
+ </fieldset>
165
+
166
+ <!-- Actions -->
167
+ <div style="display: flex; gap: 1rem;">
168
+ <%= f.submit "Preview Generated Code", class: "btn btn-primary" %>
169
+ <%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none;" %>
170
+ </div>
171
+ <% end %>
172
+
173
+ <script>
174
+ // Client-side form validation
175
+ function validateForm() {
176
+ const form = document.getElementById('trigger-generator-form');
177
+ if (!form) return true;
178
+
179
+ let isValid = true;
180
+ const errors = [];
181
+
182
+ // Validate trigger name
183
+ const triggerName = form.querySelector('[name="pg_sql_triggers_generator_form[trigger_name]"]')?.value?.trim();
184
+ if (!triggerName) {
185
+ errors.push('Trigger name is required');
186
+ isValid = false;
187
+ } else if (!/^[a-z0-9_]+$/.test(triggerName)) {
188
+ errors.push('Trigger name must contain only lowercase letters, numbers, and underscores');
189
+ isValid = false;
190
+ }
191
+
192
+ // Validate table name
193
+ const tableName = form.querySelector('[name="pg_sql_triggers_generator_form[table_name]"]')?.value?.trim();
194
+ if (!tableName) {
195
+ errors.push('Table name is required');
196
+ isValid = false;
197
+ }
198
+
199
+ // Validate function name
200
+ const functionName = form.querySelector('[name="pg_sql_triggers_generator_form[function_name]"]')?.value?.trim();
201
+ if (!functionName) {
202
+ errors.push('Function name is required');
203
+ isValid = false;
204
+ } else if (!/^[a-z0-9_]+$/.test(functionName)) {
205
+ errors.push('Function name must contain only lowercase letters, numbers, and underscores');
206
+ isValid = false;
207
+ }
208
+
209
+ // Validate function body
210
+ const functionBody = form.querySelector('[name="pg_sql_triggers_generator_form[function_body]"]')?.value?.trim();
211
+ if (!functionBody) {
212
+ errors.push('Function body is required');
213
+ isValid = false;
214
+ } else if (functionName && !functionBody.match(new RegExp('CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:[^\\s(]+\\.)?' + functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\(', 'i'))) {
215
+ errors.push('Function body should define function \'' + functionName + '\'');
216
+ isValid = false;
217
+ }
218
+
219
+ // Validate at least one event is selected
220
+ const eventCheckboxes = form.querySelectorAll('[name="pg_sql_triggers_generator_form[events][]"]:checked');
221
+ if (eventCheckboxes.length === 0) {
222
+ errors.push('At least one event must be selected');
223
+ isValid = false;
224
+ }
225
+
226
+ // Validate version
227
+ const version = form.querySelector('[name="pg_sql_triggers_generator_form[version]"]')?.value?.trim();
228
+ if (!version) {
229
+ errors.push('Version is required');
230
+ isValid = false;
231
+ } else {
232
+ const versionNum = parseInt(version, 10);
233
+ if (isNaN(versionNum) || versionNum < 1) {
234
+ errors.push('Version must be a positive integer');
235
+ isValid = false;
236
+ }
237
+ }
238
+
239
+ // Display errors
240
+ let errorDiv = document.getElementById('form-validation-errors');
241
+ if (!errorDiv) {
242
+ errorDiv = document.createElement('div');
243
+ errorDiv.id = 'form-validation-errors';
244
+ errorDiv.style.cssText = 'margin-bottom: 1rem; padding: 1rem; background: #f8d7da; border-left: 4px solid #dc3545; border-radius: 4px;';
245
+ form.insertBefore(errorDiv, form.firstChild);
246
+ }
247
+
248
+ if (!isValid) {
249
+ 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>';
250
+ errorDiv.style.display = 'block';
251
+ // Scroll to top of form
252
+ errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
253
+ } else {
254
+ errorDiv.style.display = 'none';
255
+ }
256
+
257
+ return isValid;
258
+ }
259
+
260
+ // Generate function body template
261
+ function generateFunctionBody(functionName) {
262
+ return 'CREATE OR REPLACE FUNCTION ' + (functionName || 'function_name') + '()\n' +
263
+ 'RETURNS TRIGGER AS $$\n' +
264
+ 'BEGIN\n' +
265
+ ' -- Your trigger logic here\n' +
266
+ ' RETURN NEW;\n' +
267
+ 'END;\n' +
268
+ '$$ LANGUAGE plpgsql;';
269
+ }
270
+
271
+ // Check if textarea value matches the template pattern (so we know it's still the default)
272
+ function isTemplateValue(value) {
273
+ if (!value || value.trim() === '') return true;
274
+ // Check if it matches the template pattern
275
+ const templatePattern = /^CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+\w+\(\)\s+RETURNS\s+TRIGGER/i;
276
+ return templatePattern.test(value.trim());
277
+ }
278
+
279
+ // Update function body value and placeholder when function name changes
280
+ document.getElementById('function-name-input')?.addEventListener('input', function(e) {
281
+ const functionName = e.target.value || 'function_name';
282
+ const functionBodyTextarea = document.getElementById('function-body-textarea');
283
+ if (functionBodyTextarea) {
284
+ const newTemplate = generateFunctionBody(functionName);
285
+
286
+ // Update placeholder
287
+ functionBodyTextarea.placeholder = newTemplate;
288
+
289
+ // Update value only if textarea is empty or matches template pattern
290
+ if (!functionBodyTextarea.value || isTemplateValue(functionBodyTextarea.value)) {
291
+ functionBodyTextarea.value = newTemplate;
292
+ }
293
+ }
294
+ });
295
+
296
+ // Client-side validation for table selection
297
+ document.getElementById('table-name-select')?.addEventListener('change', function(e) {
298
+ const tableName = e.target.value;
299
+ const messageDiv = document.getElementById('table-validation-message');
300
+ const infoDiv = document.getElementById('table-triggers-info');
301
+ const triggersList = document.getElementById('table-triggers-list');
302
+
303
+ if (!tableName) {
304
+ messageDiv.innerHTML = '';
305
+ if (infoDiv) infoDiv.style.display = 'none';
306
+ return;
307
+ }
308
+
309
+ messageDiv.innerHTML = '<span style="color: #6c757d;">Validating...</span>';
310
+
311
+ fetch('<%= validate_table_generator_index_path %>', {
312
+ method: 'POST',
313
+ headers: {
314
+ 'Content-Type': 'application/json',
315
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
316
+ },
317
+ body: JSON.stringify({ table_name: tableName })
318
+ })
319
+ .then(response => response.json())
320
+ .then(data => {
321
+ if (data.valid) {
322
+ messageDiv.innerHTML = '<span style="color: #28a745;">✓ Table exists (' + data.column_count + ' columns)</span>';
323
+
324
+ // Try to fetch existing triggers for this table
325
+ if (infoDiv && triggersList) {
326
+ fetch('<%= tables_path %>/' + encodeURIComponent(tableName) + '.json', {
327
+ headers: {
328
+ 'Accept': 'application/json',
329
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
330
+ }
331
+ })
332
+ .then(response => response.json())
333
+ .then(data => {
334
+ if (data.registry_triggers && data.registry_triggers.length > 0) {
335
+ let triggersHtml = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
336
+ data.registry_triggers.forEach(trigger => {
337
+ triggersHtml += '<div style="padding: 0.5rem; background: white; border-radius: 4px; border-left: 3px solid #007bff;">';
338
+ triggersHtml += '<strong>' + trigger.trigger_name + '</strong> ';
339
+ triggersHtml += '<span class="badge ' + (trigger.enabled ? 'badge-success' : 'badge-danger') + '" style="font-size: 0.75rem;">' + (trigger.enabled ? 'Enabled' : 'Disabled') + '</span>';
340
+ if (trigger.function_name) {
341
+ triggersHtml += '<br><small style="color: #6c757d;">Function: <code>' + trigger.function_name + '</code></small>';
342
+ }
343
+ triggersHtml += '</div>';
344
+ });
345
+ triggersHtml += '</div>';
346
+ 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>';
347
+ triggersList.innerHTML = triggersHtml;
348
+ infoDiv.style.display = 'block';
349
+ } else {
350
+ infoDiv.style.display = 'none';
351
+ }
352
+ })
353
+ .catch(error => {
354
+ console.error('Failed to fetch triggers:', error);
355
+ // Fallback: show link to view table details
356
+ triggersList.innerHTML = '<a href="<%= tables_path %>/' + encodeURIComponent(tableName) + '" target="_blank" style="color: #007bff; text-decoration: none;">View table details →</a>';
357
+ infoDiv.style.display = 'block';
358
+ });
359
+ }
360
+ } else {
361
+ messageDiv.innerHTML = '<span style="color: #dc3545;">✗ Table not found in database</span>';
362
+ if (infoDiv) infoDiv.style.display = 'none';
363
+ }
364
+ })
365
+ .catch(error => {
366
+ messageDiv.innerHTML = '<span style="color: #856404;">⚠ Validation unavailable</span>';
367
+ if (infoDiv) infoDiv.style.display = 'none';
368
+ });
369
+ });
370
+ </script>