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,179 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
*= require_self
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
body {
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
background: #f5f7fa;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
table {
|
|
16
|
+
width: 100%;
|
|
17
|
+
border-collapse: collapse;
|
|
18
|
+
background: white;
|
|
19
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
th, td {
|
|
23
|
+
padding: 0.75rem;
|
|
24
|
+
text-align: left;
|
|
25
|
+
border-bottom: 1px solid #e1e8ed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
th {
|
|
29
|
+
background: #f8f9fa;
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
color: #495057;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.badge {
|
|
35
|
+
display: inline-block;
|
|
36
|
+
padding: 0.25rem 0.5rem;
|
|
37
|
+
border-radius: 3px;
|
|
38
|
+
font-size: 0.875rem;
|
|
39
|
+
font-weight: 500;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.badge-success {
|
|
43
|
+
background: #d4edda;
|
|
44
|
+
color: #155724;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.badge-danger {
|
|
48
|
+
background: #f8d7da;
|
|
49
|
+
color: #721c24;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.badge-warning {
|
|
53
|
+
background: #fff3cd;
|
|
54
|
+
color: #856404;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.badge-info {
|
|
58
|
+
background: #d1ecf1;
|
|
59
|
+
color: #0c5460;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.btn {
|
|
63
|
+
display: inline-block;
|
|
64
|
+
padding: 0.5rem 1rem;
|
|
65
|
+
border-radius: 4px;
|
|
66
|
+
text-decoration: none;
|
|
67
|
+
font-weight: 500;
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
border: none;
|
|
70
|
+
transition: all 0.2s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.btn-primary {
|
|
74
|
+
background: #007bff;
|
|
75
|
+
color: white;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.btn-primary:hover {
|
|
79
|
+
background: #0056b3;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.btn-danger {
|
|
83
|
+
background: #dc3545;
|
|
84
|
+
color: white;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.btn-danger:hover {
|
|
88
|
+
background: #c82333;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.btn-success {
|
|
92
|
+
background: #28a745;
|
|
93
|
+
color: white;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.btn-success:hover {
|
|
97
|
+
background: #218838;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Form styles */
|
|
101
|
+
fieldset {
|
|
102
|
+
border: 1px solid #dee2e6;
|
|
103
|
+
padding: 1rem;
|
|
104
|
+
margin-bottom: 2rem;
|
|
105
|
+
border-radius: 4px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
legend {
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
color: #495057;
|
|
111
|
+
padding: 0 0.5rem;
|
|
112
|
+
width: auto;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
input[type="text"],
|
|
116
|
+
input[type="number"],
|
|
117
|
+
select,
|
|
118
|
+
textarea {
|
|
119
|
+
width: 100%;
|
|
120
|
+
padding: 0.5rem;
|
|
121
|
+
border: 1px solid #ced4da;
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
font-family: inherit;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
input[type="text"]:focus,
|
|
127
|
+
input[type="number"]:focus,
|
|
128
|
+
select:focus,
|
|
129
|
+
textarea:focus {
|
|
130
|
+
outline: none;
|
|
131
|
+
border-color: #007bff;
|
|
132
|
+
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* Code blocks */
|
|
136
|
+
pre {
|
|
137
|
+
background: #f8f9fa;
|
|
138
|
+
padding: 1rem;
|
|
139
|
+
border-radius: 4px;
|
|
140
|
+
overflow-x: auto;
|
|
141
|
+
border: 1px solid #dee2e6;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
code {
|
|
145
|
+
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
|
146
|
+
font-size: 0.9rem;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Alert boxes */
|
|
150
|
+
.alert {
|
|
151
|
+
padding: 1rem;
|
|
152
|
+
border-radius: 4px;
|
|
153
|
+
margin-bottom: 1rem;
|
|
154
|
+
border-left: 4px solid;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.alert-info {
|
|
158
|
+
background: #d1ecf1;
|
|
159
|
+
border-color: #0c5460;
|
|
160
|
+
color: #0c5460;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.alert-warning {
|
|
164
|
+
background: #fff3cd;
|
|
165
|
+
border-color: #ffc107;
|
|
166
|
+
color: #856404;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.alert-success {
|
|
170
|
+
background: #d4edda;
|
|
171
|
+
border-color: #28a745;
|
|
172
|
+
color: #155724;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.alert-danger {
|
|
176
|
+
background: #f8d7da;
|
|
177
|
+
border-color: #dc3545;
|
|
178
|
+
color: #721c24;
|
|
179
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
include PgSqlTriggers::Engine.routes.url_helpers
|
|
6
|
+
|
|
7
|
+
protect_from_forgery with: :exception
|
|
8
|
+
layout "pg_sql_triggers/application"
|
|
9
|
+
|
|
10
|
+
before_action :check_permissions?
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def check_permissions?
|
|
15
|
+
# Override this method in host application to implement custom permission checks
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current_actor
|
|
20
|
+
# Override this in host application to provide actual user
|
|
21
|
+
{
|
|
22
|
+
type: current_user_type,
|
|
23
|
+
id: current_user_id
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def current_user_type
|
|
28
|
+
"User"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def current_user_id
|
|
32
|
+
"unknown"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@triggers = PgSqlTriggers::TriggerRegistry.order(created_at: :desc)
|
|
7
|
+
@stats = {
|
|
8
|
+
total: @triggers.count,
|
|
9
|
+
enabled: @triggers.enabled.count,
|
|
10
|
+
disabled: @triggers.disabled.count,
|
|
11
|
+
drifted: 0 # Will be calculated by Drift::Detector
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# Migration status with pagination
|
|
15
|
+
begin
|
|
16
|
+
all_migrations = PgSqlTriggers::Migrator.status
|
|
17
|
+
@pending_migrations = PgSqlTriggers::Migrator.pending_migrations
|
|
18
|
+
@current_migration_version = PgSqlTriggers::Migrator.current_version
|
|
19
|
+
|
|
20
|
+
# Pagination
|
|
21
|
+
@per_page = (params[:per_page] || 20).to_i
|
|
22
|
+
@per_page = [@per_page, 100].min # Cap at 100
|
|
23
|
+
@page = (params[:page] || 1).to_i
|
|
24
|
+
@total_migrations = all_migrations.count
|
|
25
|
+
@total_pages = @total_migrations.positive? ? (@total_migrations.to_f / @per_page).ceil : 1
|
|
26
|
+
@page = @page.clamp(1, @total_pages) # Ensure page is within valid range
|
|
27
|
+
|
|
28
|
+
offset = (@page - 1) * @per_page
|
|
29
|
+
@migration_status = all_migrations.slice(offset, @per_page) || []
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Rails.logger.error("Failed to fetch migration status: #{e.message}")
|
|
32
|
+
@migration_status = []
|
|
33
|
+
@pending_migrations = []
|
|
34
|
+
@current_migration_version = 0
|
|
35
|
+
@total_migrations = 0
|
|
36
|
+
@total_pages = 1
|
|
37
|
+
@page = 1
|
|
38
|
+
@per_page = 20
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
class GeneratorController < ApplicationController
|
|
5
|
+
# Permissions: Require OPERATOR level for generation
|
|
6
|
+
before_action :check_operator_permission
|
|
7
|
+
|
|
8
|
+
# GET /generator/new
|
|
9
|
+
# Display the multi-step form wizard
|
|
10
|
+
def new
|
|
11
|
+
@form = PgSqlTriggers::Generator::Form.new
|
|
12
|
+
@available_tables = fetch_available_tables
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# POST /generator/preview
|
|
16
|
+
# Preview generated DSL and function stub (AJAX or regular POST)
|
|
17
|
+
def preview
|
|
18
|
+
@form = PgSqlTriggers::Generator::Form.new(generator_params)
|
|
19
|
+
|
|
20
|
+
if @form.valid?
|
|
21
|
+
# Validate SQL function body (required field)
|
|
22
|
+
@sql_validation = validate_function_sql(@form)
|
|
23
|
+
|
|
24
|
+
@dsl_content = PgSqlTriggers::Generator::Service.generate_dsl(@form)
|
|
25
|
+
# Use function_body (required)
|
|
26
|
+
@function_content = @form.function_body
|
|
27
|
+
@file_paths = PgSqlTriggers::Generator::Service.file_paths(@form)
|
|
28
|
+
|
|
29
|
+
render :preview
|
|
30
|
+
else
|
|
31
|
+
@available_tables = fetch_available_tables
|
|
32
|
+
render :new
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# POST /generator/create
|
|
37
|
+
# Actually create the files and register in TriggerRegistry
|
|
38
|
+
def create
|
|
39
|
+
@form = PgSqlTriggers::Generator::Form.new(generator_params)
|
|
40
|
+
|
|
41
|
+
if @form.valid?
|
|
42
|
+
# Validate SQL function body (required field)
|
|
43
|
+
sql_validation = validate_function_sql(@form)
|
|
44
|
+
unless sql_validation[:valid]
|
|
45
|
+
flash.now[:alert] = "Cannot create trigger: SQL validation failed - #{sql_validation[:error]}"
|
|
46
|
+
@available_tables = fetch_available_tables
|
|
47
|
+
@dsl_content = PgSqlTriggers::Generator::Service.generate_dsl(@form)
|
|
48
|
+
@function_content = @form.function_body
|
|
49
|
+
@file_paths = PgSqlTriggers::Generator::Service.file_paths(@form)
|
|
50
|
+
@sql_validation = sql_validation
|
|
51
|
+
render :preview
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result = PgSqlTriggers::Generator::Service.create_trigger(@form, actor: current_actor)
|
|
56
|
+
|
|
57
|
+
if result[:success]
|
|
58
|
+
files_msg = "Migration: #{result[:migration_path]}, DSL: #{result[:dsl_path]}"
|
|
59
|
+
redirect_to root_path,
|
|
60
|
+
notice: "Trigger generated successfully. Files created: #{files_msg}"
|
|
61
|
+
else
|
|
62
|
+
flash[:alert] = "Generation failed: #{result[:error]}"
|
|
63
|
+
@available_tables = fetch_available_tables
|
|
64
|
+
render :new
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
@available_tables = fetch_available_tables
|
|
68
|
+
render :new
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# POST /generator/validate_table (AJAX)
|
|
73
|
+
# Validate that table exists in database
|
|
74
|
+
def validate_table
|
|
75
|
+
# Extract table_name from JSON request body
|
|
76
|
+
# Rails should parse JSON automatically, but handle both cases
|
|
77
|
+
table_name = extract_table_name_from_request
|
|
78
|
+
|
|
79
|
+
if table_name.blank?
|
|
80
|
+
render json: { valid: false, error: "Table name is required" }, status: :bad_request
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
validator = PgSqlTriggers::DatabaseIntrospection.new
|
|
85
|
+
result = validator.validate_table(table_name)
|
|
86
|
+
render json: result
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# GET /generator/tables (AJAX)
|
|
90
|
+
# Fetch list of tables for dropdown
|
|
91
|
+
def tables
|
|
92
|
+
tables = fetch_available_tables
|
|
93
|
+
render json: { tables: tables }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def generator_params
|
|
99
|
+
params.expect(
|
|
100
|
+
pg_sql_triggers_generator_form: [:trigger_name, :table_name, :function_name, :version,
|
|
101
|
+
:enabled, :condition, :generate_function_stub, :function_body,
|
|
102
|
+
{ events: [], environments: [] }]
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def check_operator_permission
|
|
107
|
+
return if PgSqlTriggers::Permissions.can?(current_actor, :apply_trigger)
|
|
108
|
+
|
|
109
|
+
redirect_to root_path, alert: "Insufficient permissions. Operator role required."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch_available_tables
|
|
113
|
+
PgSqlTriggers::DatabaseIntrospection.new.list_tables
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
Rails.logger.error("Failed to fetch tables: #{e.message}")
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def extract_table_name_from_request
|
|
120
|
+
# Rails automatically parses JSON request bodies when Content-Type is application/json
|
|
121
|
+
# The parameters are available directly in params
|
|
122
|
+
table_name = params[:table_name]
|
|
123
|
+
|
|
124
|
+
# If not found, try accessing as string key (some Rails versions use string keys for JSON)
|
|
125
|
+
table_name ||= params["table_name"] if params.key?("table_name")
|
|
126
|
+
|
|
127
|
+
table_name
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_function_sql(form)
|
|
131
|
+
return nil if form.function_body.blank?
|
|
132
|
+
|
|
133
|
+
# Create a temporary trigger registry object for validation
|
|
134
|
+
temp_registry = PgSqlTriggers::TriggerRegistry.new(
|
|
135
|
+
trigger_name: form.trigger_name,
|
|
136
|
+
function_body: form.function_body
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
validator = PgSqlTriggers::Testing::SyntaxValidator.new(temp_registry)
|
|
140
|
+
validator.validate_function_syntax
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
{ valid: false, error: "Validation error: #{e.message}" }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
# Controller for managing trigger migrations via web UI
|
|
5
|
+
# Provides actions to run migrations up, down, and redo
|
|
6
|
+
class MigrationsController < ApplicationController
|
|
7
|
+
def up
|
|
8
|
+
target_version = params[:version]&.to_i
|
|
9
|
+
PgSqlTriggers::Migrator.ensure_migrations_table!
|
|
10
|
+
|
|
11
|
+
if target_version
|
|
12
|
+
PgSqlTriggers::Migrator.run_up(target_version)
|
|
13
|
+
flash[:success] = "Migration #{target_version} applied successfully."
|
|
14
|
+
else
|
|
15
|
+
pending = PgSqlTriggers::Migrator.pending_migrations
|
|
16
|
+
if pending.any?
|
|
17
|
+
PgSqlTriggers::Migrator.run_up
|
|
18
|
+
count = pending.count
|
|
19
|
+
flash[:success] = "Applied #{count} pending migration(s) successfully."
|
|
20
|
+
else
|
|
21
|
+
flash[:info] = "No pending migrations to apply."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
redirect_to root_path
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger.error("Migration up failed: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
27
|
+
flash[:error] = "Failed to apply migration: #{e.message}"
|
|
28
|
+
redirect_to root_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def down
|
|
32
|
+
target_version = params[:version]&.to_i
|
|
33
|
+
PgSqlTriggers::Migrator.ensure_migrations_table!
|
|
34
|
+
|
|
35
|
+
current_version = PgSqlTriggers::Migrator.current_version
|
|
36
|
+
if current_version.zero?
|
|
37
|
+
flash[:warning] = "No migrations to rollback."
|
|
38
|
+
redirect_to root_path
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if target_version
|
|
43
|
+
PgSqlTriggers::Migrator.run_down(target_version)
|
|
44
|
+
flash[:success] = "Migration version #{target_version} rolled back successfully."
|
|
45
|
+
else
|
|
46
|
+
# Rollback one migration by default
|
|
47
|
+
PgSqlTriggers::Migrator.run_down
|
|
48
|
+
flash[:success] = "Rolled back last migration successfully."
|
|
49
|
+
end
|
|
50
|
+
redirect_to root_path
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Rails.logger.error("Migration down failed: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
53
|
+
flash[:error] = "Failed to rollback migration: #{e.message}"
|
|
54
|
+
redirect_to root_path
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def redo
|
|
58
|
+
target_version = params[:version]&.to_i
|
|
59
|
+
PgSqlTriggers::Migrator.ensure_migrations_table!
|
|
60
|
+
|
|
61
|
+
if target_version
|
|
62
|
+
PgSqlTriggers::Migrator.run_down(target_version)
|
|
63
|
+
PgSqlTriggers::Migrator.run_up(target_version)
|
|
64
|
+
flash[:success] = "Migration #{target_version} redone successfully."
|
|
65
|
+
else
|
|
66
|
+
current_version = PgSqlTriggers::Migrator.current_version
|
|
67
|
+
if current_version.zero?
|
|
68
|
+
flash[:warning] = "No migrations to redo."
|
|
69
|
+
redirect_to root_path
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
PgSqlTriggers::Migrator.run_down
|
|
74
|
+
PgSqlTriggers::Migrator.run_up
|
|
75
|
+
flash[:success] = "Last migration redone successfully."
|
|
76
|
+
end
|
|
77
|
+
redirect_to root_path
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Rails.logger.error("Migration redo failed: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
80
|
+
flash[:error] = "Failed to redo migration: #{e.message}"
|
|
81
|
+
redirect_to root_path
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
class TablesController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
all_tables = PgSqlTriggers::DatabaseIntrospection.new.tables_with_triggers
|
|
7
|
+
# Only show tables that have at least one trigger
|
|
8
|
+
@tables_with_triggers = all_tables.select { |t| t[:trigger_count].positive? }
|
|
9
|
+
@total_tables = @tables_with_triggers.count
|
|
10
|
+
@tables_with_trigger_count = @tables_with_triggers.count
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
@table_info = PgSqlTriggers::DatabaseIntrospection.new.table_triggers(params[:id])
|
|
15
|
+
@columns = PgSqlTriggers::DatabaseIntrospection.new.table_columns(params[:id])
|
|
16
|
+
|
|
17
|
+
respond_to do |format|
|
|
18
|
+
format.html
|
|
19
|
+
format.json do
|
|
20
|
+
render json: {
|
|
21
|
+
table_name: @table_info[:table_name],
|
|
22
|
+
registry_triggers: @table_info[:registry_triggers].map do |t|
|
|
23
|
+
{
|
|
24
|
+
id: t.id,
|
|
25
|
+
trigger_name: t.trigger_name,
|
|
26
|
+
function_name: if t.definition.present?
|
|
27
|
+
begin
|
|
28
|
+
JSON.parse(t.definition)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
{}
|
|
31
|
+
end["function_name"]
|
|
32
|
+
end,
|
|
33
|
+
enabled: t.enabled,
|
|
34
|
+
version: t.version,
|
|
35
|
+
source: t.source
|
|
36
|
+
}
|
|
37
|
+
end,
|
|
38
|
+
database_triggers: @table_info[:database_triggers]
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
class TriggerRegistry < PgSqlTriggers::ApplicationRecord
|
|
5
|
+
self.table_name = "pg_sql_triggers_registry"
|
|
6
|
+
|
|
7
|
+
# Validations
|
|
8
|
+
validates :trigger_name, presence: true, uniqueness: true
|
|
9
|
+
validates :table_name, presence: true
|
|
10
|
+
validates :version, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
11
|
+
validates :checksum, presence: true
|
|
12
|
+
validates :source, presence: true, inclusion: { in: %w[dsl generated manual_sql] }
|
|
13
|
+
|
|
14
|
+
# Scopes
|
|
15
|
+
scope :enabled, -> { where(enabled: true) }
|
|
16
|
+
scope :disabled, -> { where(enabled: false) }
|
|
17
|
+
scope :for_table, ->(table_name) { where(table_name: table_name) }
|
|
18
|
+
scope :for_environment, ->(env) { where(environment: [env, nil]) }
|
|
19
|
+
scope :by_source, ->(source) { where(source: source) }
|
|
20
|
+
|
|
21
|
+
# Drift states
|
|
22
|
+
def drift_state
|
|
23
|
+
# This will be implemented by the Drift::Detector
|
|
24
|
+
PgSqlTriggers::Drift.detect(trigger_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def enable!
|
|
28
|
+
# Check if trigger exists in database before trying to enable it
|
|
29
|
+
trigger_exists = false
|
|
30
|
+
begin
|
|
31
|
+
introspection = PgSqlTriggers::DatabaseIntrospection.new
|
|
32
|
+
trigger_exists = introspection.trigger_exists?(trigger_name)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
# If checking fails, assume trigger doesn't exist and continue
|
|
35
|
+
Rails.logger.warn("Could not check if trigger exists: #{e.message}") if defined?(Rails.logger)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if trigger_exists
|
|
39
|
+
begin
|
|
40
|
+
# Enable the trigger in PostgreSQL
|
|
41
|
+
sql = "ALTER TABLE #{quote_identifier(table_name)} ENABLE TRIGGER #{quote_identifier(trigger_name)};"
|
|
42
|
+
ActiveRecord::Base.connection.execute(sql)
|
|
43
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
44
|
+
# If trigger doesn't exist or can't be enabled, continue to update registry
|
|
45
|
+
Rails.logger.warn("Could not enable trigger: #{e.message}") if defined?(Rails.logger)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Update the registry record (always update, even if trigger doesn't exist)
|
|
50
|
+
update!(enabled: true)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def disable!
|
|
54
|
+
# Check if trigger exists in database before trying to disable it
|
|
55
|
+
trigger_exists = false
|
|
56
|
+
begin
|
|
57
|
+
introspection = PgSqlTriggers::DatabaseIntrospection.new
|
|
58
|
+
trigger_exists = introspection.trigger_exists?(trigger_name)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
# If checking fails, assume trigger doesn't exist and continue
|
|
61
|
+
Rails.logger.warn("Could not check if trigger exists: #{e.message}") if defined?(Rails.logger)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if trigger_exists
|
|
65
|
+
begin
|
|
66
|
+
# Disable the trigger in PostgreSQL
|
|
67
|
+
sql = "ALTER TABLE #{quote_identifier(table_name)} DISABLE TRIGGER #{quote_identifier(trigger_name)};"
|
|
68
|
+
ActiveRecord::Base.connection.execute(sql)
|
|
69
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
70
|
+
# If trigger doesn't exist or can't be disabled, continue to update registry
|
|
71
|
+
Rails.logger.warn("Could not disable trigger: #{e.message}") if defined?(Rails.logger)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Update the registry record (always update, even if trigger doesn't exist)
|
|
76
|
+
update!(enabled: false)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def quote_identifier(identifier)
|
|
82
|
+
ActiveRecord::Base.connection.quote_table_name(identifier.to_s)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def calculate_checksum
|
|
86
|
+
Digest::SHA256.hexdigest([trigger_name, table_name, version, function_body, condition].join)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def verify!
|
|
90
|
+
update!(last_verified_at: Time.current)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>PgSqlTriggers - PostgreSQL Trigger Control Plane</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= stylesheet_link_tag "pg_sql_triggers/application", media: "all" %>
|
|
9
|
+
<%= javascript_include_tag "pg_sql_triggers/application" %>
|
|
10
|
+
</head>
|
|
11
|
+
|
|
12
|
+
<body>
|
|
13
|
+
<nav style="background: #2c3e50; padding: 1rem; color: white;">
|
|
14
|
+
<div style="max-width: 1200px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center;">
|
|
15
|
+
<h1 style="margin: 0; font-size: 1.5rem;">
|
|
16
|
+
<%= link_to "PgSqlTriggers", root_path, style: "color: white; text-decoration: none;" %>
|
|
17
|
+
</h1>
|
|
18
|
+
<div>
|
|
19
|
+
<%= link_to "Dashboard", root_path, style: "color: white; margin-right: 1rem;" %>
|
|
20
|
+
<%= link_to "Tables", tables_path, style: "color: white; margin-right: 1rem;" %>
|
|
21
|
+
<%= link_to "Generator", new_generator_path, style: "color: white;" %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</nav>
|
|
25
|
+
|
|
26
|
+
<main style="max-width: 1200px; margin: 2rem auto; padding: 0 1rem;">
|
|
27
|
+
<% if flash[:success] %>
|
|
28
|
+
<div style="background: #d4edda; color: #155724; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #28a745;">
|
|
29
|
+
<%= flash[:success] %>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
|
|
33
|
+
<% if flash[:error] %>
|
|
34
|
+
<div style="background: #f8d7da; color: #721c24; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #dc3545;">
|
|
35
|
+
<%= flash[:error] %>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
|
|
39
|
+
<% if flash[:warning] %>
|
|
40
|
+
<div style="background: #fff3cd; color: #856404; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #ffc107;">
|
|
41
|
+
<%= flash[:warning] %>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<% if flash[:info] %>
|
|
46
|
+
<div style="background: #d1ecf1; color: #0c5460; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #17a2b8;">
|
|
47
|
+
<%= flash[:info] %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<% if flash[:notice] %>
|
|
52
|
+
<div style="background: #d4edda; color: #155724; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #28a745;">
|
|
53
|
+
<%= flash[:notice] %>
|
|
54
|
+
</div>
|
|
55
|
+
<% end %>
|
|
56
|
+
|
|
57
|
+
<% if flash[:alert] %>
|
|
58
|
+
<div style="background: #f8d7da; color: #721c24; padding: 1rem; margin-bottom: 1rem; border-radius: 4px; border-left: 4px solid #dc3545;">
|
|
59
|
+
<%= flash[:alert] %>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
63
|
+
<%= yield %>
|
|
64
|
+
</main>
|
|
65
|
+
|
|
66
|
+
<footer style="background: #ecf0f1; padding: 2rem; margin-top: 4rem; text-align: center;">
|
|
67
|
+
<p style="margin: 0; color: #7f8c8d;">
|
|
68
|
+
PgSqlTriggers - A PostgreSQL Trigger Control Plane for Rails
|
|
69
|
+
</p>
|
|
70
|
+
</footer>
|
|
71
|
+
</body>
|
|
72
|
+
</html>
|