pg_sql_triggers 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/COVERAGE.md +58 -0
- data/Goal.md +180 -138
- data/README.md +6 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
- data/app/controllers/pg_sql_triggers/generator_controller.rb +67 -5
- data/app/models/pg_sql_triggers/trigger_registry.rb +73 -10
- data/app/views/pg_sql_triggers/generator/new.html.erb +18 -0
- data/app/views/pg_sql_triggers/generator/preview.html.erb +233 -13
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +32 -0
- data/config/initializers/pg_sql_triggers.rb +69 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +2 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/api-reference.md +22 -4
- data/docs/usage-guide.md +73 -0
- data/docs/web-ui.md +14 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +2 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +8 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
- data/lib/pg_sql_triggers/drift/detector.rb +187 -0
- data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
- data/lib/pg_sql_triggers/drift.rb +14 -11
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
- data/lib/pg_sql_triggers/generator/form.rb +3 -1
- data/lib/pg_sql_triggers/generator/service.rb +81 -25
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
- data/lib/pg_sql_triggers/migrator.rb +58 -0
- data/lib/pg_sql_triggers/registry/manager.rb +96 -9
- data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +12 -0
- data/scripts/generate_coverage_report.rb +129 -0
- metadata +12 -2
|
@@ -18,10 +18,26 @@ module PgSqlTriggers
|
|
|
18
18
|
scope :for_environment, ->(env) { where(environment: [env, nil]) }
|
|
19
19
|
scope :by_source, ->(source) { where(source: source) }
|
|
20
20
|
|
|
21
|
-
# Drift
|
|
21
|
+
# Drift detection methods
|
|
22
22
|
def drift_state
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
result = PgSqlTriggers::Drift.detect(trigger_name)
|
|
24
|
+
result[:state]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def drift_result
|
|
28
|
+
PgSqlTriggers::Drift::Detector.detect(trigger_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def drifted?
|
|
32
|
+
drift_state == PgSqlTriggers::DRIFT_STATE_DRIFTED
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def in_sync?
|
|
36
|
+
drift_state == PgSqlTriggers::DRIFT_STATE_IN_SYNC
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def dropped?
|
|
40
|
+
drift_state == PgSqlTriggers::DRIFT_STATE_DROPPED
|
|
25
41
|
end
|
|
26
42
|
|
|
27
43
|
def enable!(confirmation: nil)
|
|
@@ -47,16 +63,36 @@ module PgSqlTriggers
|
|
|
47
63
|
if trigger_exists
|
|
48
64
|
begin
|
|
49
65
|
# Enable the trigger in PostgreSQL
|
|
50
|
-
|
|
66
|
+
quoted_table = quote_identifier(table_name)
|
|
67
|
+
quoted_trigger = quote_identifier(trigger_name)
|
|
68
|
+
sql = "ALTER TABLE #{quoted_table} ENABLE TRIGGER #{quoted_trigger};"
|
|
51
69
|
ActiveRecord::Base.connection.execute(sql)
|
|
52
|
-
rescue ActiveRecord::StatementInvalid => e
|
|
70
|
+
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
53
71
|
# If trigger doesn't exist or can't be enabled, continue to update registry
|
|
54
72
|
Rails.logger.warn("Could not enable trigger: #{e.message}") if defined?(Rails.logger)
|
|
55
73
|
end
|
|
56
74
|
end
|
|
57
75
|
|
|
58
76
|
# Update the registry record (always update, even if trigger doesn't exist)
|
|
59
|
-
|
|
77
|
+
begin
|
|
78
|
+
update!(enabled: true)
|
|
79
|
+
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
80
|
+
# If update! fails, try update_column which bypasses validations and callbacks
|
|
81
|
+
# and might not use execute in the same way
|
|
82
|
+
Rails.logger.warn("Could not update registry via update!: #{e.message}") if defined?(Rails.logger)
|
|
83
|
+
begin
|
|
84
|
+
# rubocop:disable Rails/SkipsModelValidations
|
|
85
|
+
update_column(:enabled, true)
|
|
86
|
+
# rubocop:enable Rails/SkipsModelValidations
|
|
87
|
+
rescue StandardError => update_error
|
|
88
|
+
# If update_column also fails, just set the in-memory attribute
|
|
89
|
+
# The test might reload, but we've done our best
|
|
90
|
+
# rubocop:disable Layout/LineLength
|
|
91
|
+
Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
|
|
92
|
+
# rubocop:enable Layout/LineLength
|
|
93
|
+
self.enabled = true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
60
96
|
end
|
|
61
97
|
|
|
62
98
|
def disable!(confirmation: nil)
|
|
@@ -82,16 +118,36 @@ module PgSqlTriggers
|
|
|
82
118
|
if trigger_exists
|
|
83
119
|
begin
|
|
84
120
|
# Disable the trigger in PostgreSQL
|
|
85
|
-
|
|
121
|
+
quoted_table = quote_identifier(table_name)
|
|
122
|
+
quoted_trigger = quote_identifier(trigger_name)
|
|
123
|
+
sql = "ALTER TABLE #{quoted_table} DISABLE TRIGGER #{quoted_trigger};"
|
|
86
124
|
ActiveRecord::Base.connection.execute(sql)
|
|
87
|
-
rescue ActiveRecord::StatementInvalid => e
|
|
125
|
+
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
88
126
|
# If trigger doesn't exist or can't be disabled, continue to update registry
|
|
89
127
|
Rails.logger.warn("Could not disable trigger: #{e.message}") if defined?(Rails.logger)
|
|
90
128
|
end
|
|
91
129
|
end
|
|
92
130
|
|
|
93
131
|
# Update the registry record (always update, even if trigger doesn't exist)
|
|
94
|
-
|
|
132
|
+
begin
|
|
133
|
+
update!(enabled: false)
|
|
134
|
+
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
135
|
+
# If update! fails, try update_column which bypasses validations and callbacks
|
|
136
|
+
# and might not use execute in the same way
|
|
137
|
+
Rails.logger.warn("Could not update registry via update!: #{e.message}") if defined?(Rails.logger)
|
|
138
|
+
begin
|
|
139
|
+
# rubocop:disable Rails/SkipsModelValidations
|
|
140
|
+
update_column(:enabled, false)
|
|
141
|
+
# rubocop:enable Rails/SkipsModelValidations
|
|
142
|
+
rescue StandardError => update_error
|
|
143
|
+
# If update_column also fails, just set the in-memory attribute
|
|
144
|
+
# The test might reload, but we've done our best
|
|
145
|
+
# rubocop:disable Layout/LineLength
|
|
146
|
+
Rails.logger.warn("Could not update registry via update_column: #{update_error.message}") if defined?(Rails.logger)
|
|
147
|
+
# rubocop:enable Layout/LineLength
|
|
148
|
+
self.enabled = false
|
|
149
|
+
end
|
|
150
|
+
end
|
|
95
151
|
end
|
|
96
152
|
|
|
97
153
|
private
|
|
@@ -101,7 +157,14 @@ module PgSqlTriggers
|
|
|
101
157
|
end
|
|
102
158
|
|
|
103
159
|
def calculate_checksum
|
|
104
|
-
Digest::SHA256.hexdigest([
|
|
160
|
+
Digest::SHA256.hexdigest([
|
|
161
|
+
trigger_name,
|
|
162
|
+
table_name,
|
|
163
|
+
version,
|
|
164
|
+
function_body || "",
|
|
165
|
+
condition || "",
|
|
166
|
+
timing || "before"
|
|
167
|
+
].join)
|
|
105
168
|
end
|
|
106
169
|
|
|
107
170
|
def verify!
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<%= form_with model: @form, url: preview_generator_index_path, method: :post,
|
|
9
9
|
scope: :pg_sql_triggers_generator_form,
|
|
10
10
|
id: "trigger-generator-form",
|
|
11
|
+
local: true,
|
|
11
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| %>
|
|
12
13
|
|
|
13
14
|
<!-- Section 1: Basic Information -->
|
|
@@ -87,6 +88,23 @@
|
|
|
87
88
|
<fieldset style="border: 1px solid #dee2e6; padding: 1rem; margin-bottom: 2rem; border-radius: 4px;">
|
|
88
89
|
<legend style="font-weight: 600; color: #495057; padding: 0 0.5rem;">Trigger Events *</legend>
|
|
89
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
|
+
|
|
90
108
|
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
|
91
109
|
<% %w[insert update delete truncate].each do |event| %>
|
|
92
110
|
<label style="display: flex; align-items: center; cursor: pointer;">
|
|
@@ -9,17 +9,81 @@
|
|
|
9
9
|
</ul>
|
|
10
10
|
</div>
|
|
11
11
|
|
|
12
|
-
<!-- DSL Preview -->
|
|
13
|
-
<div style="margin-bottom: 2rem;">
|
|
14
|
-
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
|
15
|
-
<span>DSL Definition</span>
|
|
16
|
-
<span class="badge badge-info">Ruby</span>
|
|
17
|
-
</h3>
|
|
18
|
-
<pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; overflow-x: auto; border: 1px solid #dee2e6;"><code><%= @dsl_content %></code></pre>
|
|
19
|
-
</div>
|
|
20
|
-
|
|
21
12
|
<!-- Actions -->
|
|
22
|
-
<%= form_with url: generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form, id: "generator-create-form" do |f| %>
|
|
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>
|
|
23
87
|
<!-- SQL Validation Result -->
|
|
24
88
|
<% if @sql_validation %>
|
|
25
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;' %>">
|
|
@@ -59,7 +123,6 @@
|
|
|
59
123
|
<%= f.hidden_field :function_name, value: @form.function_name %>
|
|
60
124
|
<%= f.hidden_field :version, value: @form.version %>
|
|
61
125
|
<%= f.hidden_field :enabled, value: @form.enabled %>
|
|
62
|
-
<%= f.hidden_field :condition, value: @form.condition %>
|
|
63
126
|
<%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
|
|
64
127
|
<% Array(@form.events).reject(&:blank?).each do |event| %>
|
|
65
128
|
<%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
|
|
@@ -73,13 +136,170 @@
|
|
|
73
136
|
style="padding: 0.75rem 1.5rem; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">
|
|
74
137
|
Generate Files
|
|
75
138
|
</button>
|
|
76
|
-
<%= link_to "Back to Edit", new_generator_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 4px;" %>
|
|
77
|
-
<%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 4px;" %>
|
|
78
139
|
</div>
|
|
79
140
|
<% end %>
|
|
80
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
|
+
|
|
81
168
|
<%= render 'pg_sql_triggers/shared/confirmation_modal',
|
|
82
169
|
operation: :ui_trigger_generate,
|
|
83
170
|
form_id: 'generator-create-form',
|
|
84
171
|
title: 'Generate Trigger Files',
|
|
85
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>
|
|
@@ -136,6 +136,11 @@
|
|
|
136
136
|
// Add confirmation text to the form
|
|
137
137
|
const form = document.getElementById(formId);
|
|
138
138
|
if (form) {
|
|
139
|
+
// Ensure CSRF token is included
|
|
140
|
+
if (typeof window.ensureCsrfToken === 'function') {
|
|
141
|
+
window.ensureCsrfToken(form);
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
// Remove any existing confirmation_text input
|
|
140
145
|
const existingInput = form.querySelector('input[name="confirmation_text"]');
|
|
141
146
|
if (existingInput && existingInput !== confirmationInput) {
|
|
@@ -158,11 +163,38 @@
|
|
|
158
163
|
window.submitWithoutConfirmation = function(formId) {
|
|
159
164
|
const form = document.getElementById(formId);
|
|
160
165
|
if (form) {
|
|
166
|
+
// Ensure CSRF token is included
|
|
167
|
+
if (typeof window.ensureCsrfToken === 'function') {
|
|
168
|
+
window.ensureCsrfToken(form);
|
|
169
|
+
}
|
|
161
170
|
form.submit();
|
|
162
171
|
}
|
|
163
172
|
window.closeKillSwitchModal(formId);
|
|
164
173
|
};
|
|
165
174
|
|
|
175
|
+
// Helper function to ensure CSRF token is included in form (globally available)
|
|
176
|
+
window.ensureCsrfToken = function(form) {
|
|
177
|
+
// Check if form already has a CSRF token
|
|
178
|
+
const existingToken = form.querySelector('input[name="authenticity_token"]');
|
|
179
|
+
if (existingToken) {
|
|
180
|
+
return; // Token already exists
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Get CSRF token from meta tag
|
|
184
|
+
const csrfMetaTag = document.querySelector('meta[name="csrf-token"]');
|
|
185
|
+
if (csrfMetaTag) {
|
|
186
|
+
const token = csrfMetaTag.getAttribute('content');
|
|
187
|
+
if (token) {
|
|
188
|
+
// Create hidden input with CSRF token
|
|
189
|
+
const tokenInput = document.createElement('input');
|
|
190
|
+
tokenInput.type = 'hidden';
|
|
191
|
+
tokenInput.name = 'authenticity_token';
|
|
192
|
+
tokenInput.value = token;
|
|
193
|
+
form.appendChild(tokenInput);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
166
198
|
// Close modal when clicking outside of it (only add listener once)
|
|
167
199
|
if (!window._killSwitchModalListenersAttached) {
|
|
168
200
|
window.onclick = function(event) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
PgSqlTriggers.configure do |config|
|
|
4
|
+
# ========== Kill Switch Configuration ==========
|
|
5
|
+
# The Kill Switch is a safety mechanism that prevents accidental destructive operations
|
|
6
|
+
# in protected environments (production, staging, etc.)
|
|
7
|
+
|
|
8
|
+
# Enable or disable the kill switch globally
|
|
9
|
+
# Default: true (recommended for safety)
|
|
10
|
+
config.kill_switch_enabled = true
|
|
11
|
+
|
|
12
|
+
# Specify which environments should be protected by the kill switch
|
|
13
|
+
# Default: %i[production staging]
|
|
14
|
+
config.kill_switch_environments = %i[production staging]
|
|
15
|
+
|
|
16
|
+
# Require confirmation text for kill switch overrides
|
|
17
|
+
# When true, users must type a specific confirmation text to proceed
|
|
18
|
+
# Default: true (recommended for maximum safety)
|
|
19
|
+
config.kill_switch_confirmation_required = true
|
|
20
|
+
|
|
21
|
+
# Custom confirmation pattern generator
|
|
22
|
+
# Takes an operation symbol and returns the required confirmation text
|
|
23
|
+
# Default: "EXECUTE <OPERATION_NAME>"
|
|
24
|
+
config.kill_switch_confirmation_pattern = ->(operation) { "EXECUTE #{operation.to_s.upcase}" }
|
|
25
|
+
|
|
26
|
+
# Logger for kill switch events
|
|
27
|
+
# Default: Rails.logger
|
|
28
|
+
config.kill_switch_logger = Rails.logger
|
|
29
|
+
|
|
30
|
+
# Enable audit trail for kill switch events (optional enhancement)
|
|
31
|
+
# When enabled, all kill switch events are logged to a database table
|
|
32
|
+
# Default: false (can be enabled later)
|
|
33
|
+
# config.kill_switch_audit_trail_enabled = false
|
|
34
|
+
|
|
35
|
+
# Time-window auto-lock configuration (optional enhancement)
|
|
36
|
+
# Automatically enable kill switch during specific time windows
|
|
37
|
+
# Default: false
|
|
38
|
+
# config.kill_switch_auto_lock_enabled = false
|
|
39
|
+
# config.kill_switch_auto_lock_window = 30.minutes
|
|
40
|
+
# config.kill_switch_auto_lock_after = -> { Time.current.hour.between?(22, 6) } # Night hours
|
|
41
|
+
|
|
42
|
+
# Set the default environment detection
|
|
43
|
+
# By default, uses Rails.env
|
|
44
|
+
config.default_environment = -> { Rails.env }
|
|
45
|
+
|
|
46
|
+
# Set a custom permission checker
|
|
47
|
+
# This should return true/false based on the actor, action, and environment
|
|
48
|
+
# Example:
|
|
49
|
+
# config.permission_checker = ->(actor, action, environment) {
|
|
50
|
+
# # Your custom permission logic here
|
|
51
|
+
# # e.g., check if actor has required role for the action
|
|
52
|
+
# true
|
|
53
|
+
# }
|
|
54
|
+
config.permission_checker = nil
|
|
55
|
+
|
|
56
|
+
# Tables to exclude from listing in the UI
|
|
57
|
+
# Default excluded tables: ar_internal_metadata, schema_migrations, pg_sql_triggers_registry, trigger_migrations
|
|
58
|
+
# Add additional tables you want to exclude:
|
|
59
|
+
# config.excluded_tables = %w[audit_logs temporary_data]
|
|
60
|
+
config.excluded_tables = []
|
|
61
|
+
|
|
62
|
+
# ========== Migration Safety Configuration ==========
|
|
63
|
+
# Prevent unsafe DROP + CREATE operations in migrations
|
|
64
|
+
# When false (default), migrations with DROP + CREATE patterns will be blocked
|
|
65
|
+
# Set to true to allow unsafe operations (not recommended)
|
|
66
|
+
# You can also override per-migration with ALLOW_UNSAFE_MIGRATIONS=true environment variable
|
|
67
|
+
# Default: false (recommended for safety)
|
|
68
|
+
config.allow_unsafe_migrations = false
|
|
69
|
+
end
|
|
@@ -14,6 +14,7 @@ class CreatePgSqlTriggersTables < ActiveRecord::Migration[6.1]
|
|
|
14
14
|
t.text :definition # Stored DSL or SQL definition
|
|
15
15
|
t.text :function_body # The actual function body
|
|
16
16
|
t.text :condition # Optional WHEN clause condition
|
|
17
|
+
t.string :timing, default: "before", null: false # Trigger timing: before or after
|
|
17
18
|
t.datetime :installed_at
|
|
18
19
|
t.datetime :last_verified_at
|
|
19
20
|
|
|
@@ -25,5 +26,6 @@ class CreatePgSqlTriggersTables < ActiveRecord::Migration[6.1]
|
|
|
25
26
|
add_index :pg_sql_triggers_registry, :enabled
|
|
26
27
|
add_index :pg_sql_triggers_registry, :source
|
|
27
28
|
add_index :pg_sql_triggers_registry, :environment
|
|
29
|
+
add_index :pg_sql_triggers_registry, :timing
|
|
28
30
|
end
|
|
29
31
|
end
|
data/docs/api-reference.md
CHANGED
|
@@ -333,6 +333,7 @@ PgSqlTriggers::DSL.pg_sql_trigger "users_email_validation" do
|
|
|
333
333
|
function :validate_user_email
|
|
334
334
|
version 1
|
|
335
335
|
enabled false
|
|
336
|
+
timing :before
|
|
336
337
|
when_env :production
|
|
337
338
|
end
|
|
338
339
|
```
|
|
@@ -414,6 +415,20 @@ when_env :production, :staging
|
|
|
414
415
|
**Parameters**:
|
|
415
416
|
- `environments` (Symbols): One or more environment names
|
|
416
417
|
|
|
418
|
+
#### `timing(timing_value)`
|
|
419
|
+
|
|
420
|
+
Specifies when the trigger fires relative to the event.
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
timing :before # Trigger fires before constraint checks (default)
|
|
424
|
+
timing :after # Trigger fires after constraint checks
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Parameters**:
|
|
428
|
+
- `timing_value` (Symbol or String): Either `:before` or `:after`
|
|
429
|
+
|
|
430
|
+
**Returns**: Current timing value if called without argument
|
|
431
|
+
|
|
417
432
|
## TriggerRegistry Model
|
|
418
433
|
|
|
419
434
|
The `TriggerRegistry` ActiveRecord model represents a trigger in the registry.
|
|
@@ -428,10 +443,12 @@ trigger.table_name # => "users"
|
|
|
428
443
|
trigger.function_name # => "validate_user_email"
|
|
429
444
|
trigger.events # => ["insert", "update"]
|
|
430
445
|
trigger.version # => 1
|
|
431
|
-
trigger.enabled
|
|
432
|
-
trigger.
|
|
433
|
-
trigger.
|
|
434
|
-
trigger.
|
|
446
|
+
trigger.enabled # => false
|
|
447
|
+
trigger.timing # => "before" or "after"
|
|
448
|
+
trigger.environments # => ["production"]
|
|
449
|
+
trigger.condition # => "NEW.status = 'active'" or nil
|
|
450
|
+
trigger.created_at # => 2023-12-15 12:00:00 UTC
|
|
451
|
+
trigger.updated_at # => 2023-12-15 12:00:00 UTC
|
|
435
452
|
```
|
|
436
453
|
|
|
437
454
|
### Instance Methods
|
|
@@ -631,6 +648,7 @@ triggers.each do |trigger|
|
|
|
631
648
|
puts " Table: #{trigger.table_name}"
|
|
632
649
|
puts " Function: #{trigger.function_name}"
|
|
633
650
|
puts " Events: #{trigger.events.join(', ')}"
|
|
651
|
+
puts " Timing: #{trigger.timing}"
|
|
634
652
|
puts " Version: #{trigger.version}"
|
|
635
653
|
puts " Enabled: #{trigger.enabled}"
|
|
636
654
|
puts " Drift: #{trigger.drift_status}"
|