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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +120 -0
- data/CHANGELOG.md +52 -0
- data/Goal.md +294 -0
- data/LICENSE +21 -0
- data/README.md +294 -0
- data/RELEASE.md +270 -0
- data/Rakefile +16 -0
- data/app/assets/javascripts/pg_sql_triggers/application.js +5 -0
- data/app/assets/stylesheets/pg_sql_triggers/application.css +179 -0
- data/app/controllers/pg_sql_triggers/application_controller.rb +35 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +42 -0
- data/app/controllers/pg_sql_triggers/generator_controller.rb +145 -0
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +84 -0
- data/app/controllers/pg_sql_triggers/tables_controller.rb +44 -0
- data/app/models/pg_sql_triggers/application_record.rb +7 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +93 -0
- data/app/views/layouts/pg_sql_triggers/application.html.erb +72 -0
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +225 -0
- data/app/views/pg_sql_triggers/generator/new.html.erb +370 -0
- data/app/views/pg_sql_triggers/generator/preview.html.erb +77 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +105 -0
- data/app/views/pg_sql_triggers/tables/show.html.erb +126 -0
- data/config/routes.rb +35 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +29 -0
- data/lib/generators/pg_sql_triggers/install_generator.rb +36 -0
- data/lib/generators/pg_sql_triggers/templates/README +36 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +36 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +27 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +32 -0
- data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +60 -0
- data/lib/generators/trigger/migration_generator.rb +60 -0
- data/lib/pg_sql_triggers/database_introspection.rb +251 -0
- data/lib/pg_sql_triggers/drift.rb +24 -0
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +67 -0
- data/lib/pg_sql_triggers/dsl.rb +15 -0
- data/lib/pg_sql_triggers/engine.rb +29 -0
- data/lib/pg_sql_triggers/generator/form.rb +78 -0
- data/lib/pg_sql_triggers/generator/service.rb +251 -0
- data/lib/pg_sql_triggers/generator.rb +8 -0
- data/lib/pg_sql_triggers/migration.rb +15 -0
- data/lib/pg_sql_triggers/migrator.rb +237 -0
- data/lib/pg_sql_triggers/permissions/checker.rb +33 -0
- data/lib/pg_sql_triggers/permissions.rb +35 -0
- data/lib/pg_sql_triggers/registry/manager.rb +47 -0
- data/lib/pg_sql_triggers/registry/validator.rb +15 -0
- data/lib/pg_sql_triggers/registry.rb +36 -0
- data/lib/pg_sql_triggers/sql.rb +21 -0
- data/lib/pg_sql_triggers/testing/dry_run.rb +74 -0
- data/lib/pg_sql_triggers/testing/function_tester.rb +118 -0
- data/lib/pg_sql_triggers/testing/safe_executor.rb +66 -0
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +124 -0
- data/lib/pg_sql_triggers/testing.rb +10 -0
- data/lib/pg_sql_triggers/version.rb +15 -0
- data/lib/pg_sql_triggers.rb +41 -0
- data/lib/tasks/trigger_migrations.rake +254 -0
- data/sig/pg_sql_triggers.rbs +4 -0
- 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>
|