pg_sql_triggers 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +120 -0
  4. data/CHANGELOG.md +52 -0
  5. data/Goal.md +294 -0
  6. data/LICENSE +21 -0
  7. data/README.md +294 -0
  8. data/RELEASE.md +270 -0
  9. data/Rakefile +16 -0
  10. data/app/assets/javascripts/pg_sql_triggers/application.js +5 -0
  11. data/app/assets/stylesheets/pg_sql_triggers/application.css +179 -0
  12. data/app/controllers/pg_sql_triggers/application_controller.rb +35 -0
  13. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +42 -0
  14. data/app/controllers/pg_sql_triggers/generator_controller.rb +145 -0
  15. data/app/controllers/pg_sql_triggers/migrations_controller.rb +84 -0
  16. data/app/controllers/pg_sql_triggers/tables_controller.rb +44 -0
  17. data/app/models/pg_sql_triggers/application_record.rb +7 -0
  18. data/app/models/pg_sql_triggers/trigger_registry.rb +93 -0
  19. data/app/views/layouts/pg_sql_triggers/application.html.erb +72 -0
  20. data/app/views/pg_sql_triggers/dashboard/index.html.erb +225 -0
  21. data/app/views/pg_sql_triggers/generator/new.html.erb +370 -0
  22. data/app/views/pg_sql_triggers/generator/preview.html.erb +77 -0
  23. data/app/views/pg_sql_triggers/tables/index.html.erb +105 -0
  24. data/app/views/pg_sql_triggers/tables/show.html.erb +126 -0
  25. data/config/routes.rb +35 -0
  26. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +29 -0
  27. data/lib/generators/pg_sql_triggers/install_generator.rb +36 -0
  28. data/lib/generators/pg_sql_triggers/templates/README +36 -0
  29. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +36 -0
  30. data/lib/generators/pg_sql_triggers/templates/initializer.rb +27 -0
  31. data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +32 -0
  32. data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +60 -0
  33. data/lib/generators/trigger/migration_generator.rb +60 -0
  34. data/lib/pg_sql_triggers/database_introspection.rb +251 -0
  35. data/lib/pg_sql_triggers/drift.rb +24 -0
  36. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +67 -0
  37. data/lib/pg_sql_triggers/dsl.rb +15 -0
  38. data/lib/pg_sql_triggers/engine.rb +29 -0
  39. data/lib/pg_sql_triggers/generator/form.rb +78 -0
  40. data/lib/pg_sql_triggers/generator/service.rb +251 -0
  41. data/lib/pg_sql_triggers/generator.rb +8 -0
  42. data/lib/pg_sql_triggers/migration.rb +15 -0
  43. data/lib/pg_sql_triggers/migrator.rb +237 -0
  44. data/lib/pg_sql_triggers/permissions/checker.rb +33 -0
  45. data/lib/pg_sql_triggers/permissions.rb +35 -0
  46. data/lib/pg_sql_triggers/registry/manager.rb +47 -0
  47. data/lib/pg_sql_triggers/registry/validator.rb +15 -0
  48. data/lib/pg_sql_triggers/registry.rb +36 -0
  49. data/lib/pg_sql_triggers/sql.rb +21 -0
  50. data/lib/pg_sql_triggers/testing/dry_run.rb +74 -0
  51. data/lib/pg_sql_triggers/testing/function_tester.rb +118 -0
  52. data/lib/pg_sql_triggers/testing/safe_executor.rb +66 -0
  53. data/lib/pg_sql_triggers/testing/syntax_validator.rb +124 -0
  54. data/lib/pg_sql_triggers/testing.rb +10 -0
  55. data/lib/pg_sql_triggers/version.rb +15 -0
  56. data/lib/pg_sql_triggers.rb +41 -0
  57. data/lib/tasks/trigger_migrations.rake +254 -0
  58. data/sig/pg_sql_triggers.rbs +4 -0
  59. metadata +260 -0
@@ -0,0 +1,77 @@
1
+ <h2>Preview Generated Trigger</h2>
2
+
3
+ <div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 1rem; margin-bottom: 2rem;">
4
+ <strong>Review before generating:</strong>
5
+ <ul style="margin: 0.5rem 0 0 1rem; padding: 0;">
6
+ <li>Migration file will be created at: <code><%= @file_paths[:migration] %></code></li>
7
+ <li>DSL file will be created at: <code><%= @file_paths[:dsl] %></code></li>
8
+ <li>Trigger will be registered with source: <strong>dsl</strong></li>
9
+ </ul>
10
+ </div>
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
+ <!-- Actions -->
22
+ <%= form_with url: generator_index_path, method: :post, scope: :pg_sql_triggers_generator_form do |f| %>
23
+ <!-- SQL Validation Result -->
24
+ <% if @sql_validation %>
25
+ <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;' %>">
26
+ <strong style="<%= @sql_validation[:valid] ? 'color: #155724;' : 'color: #721c24;' %>">
27
+ <%= @sql_validation[:valid] ? '✓ SQL Validation Passed' : '✗ SQL Validation Failed' %>
28
+ </strong>
29
+ <% if @sql_validation[:valid] %>
30
+ <p style="color: #155724; margin: 0.5rem 0 0 0; font-size: 0.875rem;">
31
+ <%= @sql_validation[:message] || 'Function syntax is valid' %>
32
+ </p>
33
+ <% else %>
34
+ <p style="color: #721c24; margin: 0.5rem 0 0 0; font-size: 0.875rem;">
35
+ <%= @sql_validation[:error] || 'Function syntax is invalid' %>
36
+ </p>
37
+ <% end %>
38
+ </div>
39
+ <% end %>
40
+
41
+ <!-- Function Preview -->
42
+ <div style="margin-bottom: 2rem;">
43
+ <h3 style="display: flex; align-items: center; gap: 0.5rem;">
44
+ <span>PL/pgSQL Function</span>
45
+ <span class="badge badge-info">SQL</span>
46
+ <small style="color: #6c757d; font-weight: normal;">(Editable)</small>
47
+ </h3>
48
+ <p style="color: #6c757d; margin-bottom: 0.5rem; font-size: 0.875rem;">
49
+ You can edit the function body below before generating the trigger files.
50
+ </p>
51
+ <%= f.text_area :function_body,
52
+ value: @function_content,
53
+ rows: 20,
54
+ required: true,
55
+ style: "width: 100%; padding: 1rem; 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; background: #f8f9fa; resize: vertical;" %>
56
+ </div>
57
+ <%= f.hidden_field :trigger_name, value: @form.trigger_name %>
58
+ <%= f.hidden_field :table_name, value: @form.table_name %>
59
+ <%= f.hidden_field :function_name, value: @form.function_name %>
60
+ <%= f.hidden_field :version, value: @form.version %>
61
+ <%= f.hidden_field :enabled, value: @form.enabled %>
62
+ <%= f.hidden_field :condition, value: @form.condition %>
63
+ <%= f.hidden_field :generate_function_stub, value: @form.generate_function_stub %>
64
+ <% Array(@form.events).reject(&:blank?).each do |event| %>
65
+ <%= hidden_field_tag "pg_sql_triggers_generator_form[events][]", event %>
66
+ <% end %>
67
+ <% Array(@form.environments).reject(&:blank?).each do |env| %>
68
+ <%= hidden_field_tag "pg_sql_triggers_generator_form[environments][]", env %>
69
+ <% end %>
70
+
71
+ <div style="display: flex; gap: 1rem;">
72
+ <%= f.submit "Generate Files", class: "btn btn-success",
73
+ data: { confirm: "This will create files on disk and register the trigger. Continue?" } %>
74
+ <%= link_to "Back to Edit", new_generator_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none;" %>
75
+ <%= link_to "Cancel", root_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none;" %>
76
+ </div>
77
+ <% end %>
@@ -0,0 +1,105 @@
1
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
2
+ <h2 style="margin: 0;">Database Tables & Triggers</h2>
3
+ <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-success" %>
4
+ </div>
5
+
6
+ <!-- Statistics -->
7
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
8
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
9
+ <div style="font-size: 2rem; font-weight: 600; color: #28a745;"><%= @tables_with_trigger_count %></div>
10
+ <div style="color: #6c757d;">Tables with Triggers</div>
11
+ </div>
12
+ </div>
13
+
14
+ <!-- Tables List -->
15
+ <% if @tables_with_triggers.any? %>
16
+ <div style="background: white; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden;">
17
+ <table style="margin: 0;">
18
+ <thead>
19
+ <tr>
20
+ <th style="width: 25%;">Table Name</th>
21
+ <th style="width: 10%;">Triggers</th>
22
+ <th style="width: 40%;">Trigger Names & Functions</th>
23
+ <th style="width: 15%;">Status</th>
24
+ <th style="width: 10%;">Actions</th>
25
+ </tr>
26
+ </thead>
27
+ <tbody>
28
+ <% @tables_with_triggers.each do |table| %>
29
+ <tr class="table-row">
30
+ <td>
31
+ <strong style="font-size: 1.1rem;"><%= table[:table_name] %></strong>
32
+ </td>
33
+ <td>
34
+ <span class="badge <%= table[:trigger_count] > 0 ? 'badge-success' : 'badge-danger' %>">
35
+ <%= table[:trigger_count] %>
36
+ </span>
37
+ </td>
38
+ <td>
39
+ <% if table[:trigger_count] > 0 %>
40
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
41
+ <% table[:registry_triggers].each do |trigger| %>
42
+ <div style="padding: 0.5rem; background: #f8f9fa; border-radius: 4px; border-left: 3px solid #007bff;">
43
+ <div style="font-weight: 600; color: #007bff;">
44
+ <%= trigger[:trigger_name] %>
45
+ <% if trigger[:enabled] %>
46
+ <span class="badge badge-success" style="font-size: 0.75rem; margin-left: 0.5rem;">Enabled</span>
47
+ <% else %>
48
+ <span class="badge badge-danger" style="font-size: 0.75rem; margin-left: 0.5rem;">Disabled</span>
49
+ <% end %>
50
+ </div>
51
+ <div style="font-size: 0.875rem; color: #6c757d; margin-top: 0.25rem;">
52
+ Function: <code style="background: #e9ecef; padding: 0.125rem 0.25rem; border-radius: 2px;"><%= trigger[:function_name] || 'N/A' %></code>
53
+ </div>
54
+ </div>
55
+ <% end %>
56
+ <% table[:database_triggers].each do |trigger| %>
57
+ <div style="padding: 0.5rem; background: #fff3cd; border-radius: 4px; border-left: 3px solid #ffc107;">
58
+ <div style="font-weight: 600; color: #856404;">
59
+ <%= trigger[:trigger_name] %>
60
+ <span class="badge badge-warning" style="font-size: 0.75rem; margin-left: 0.5rem;">DB Only</span>
61
+ </div>
62
+ <div style="font-size: 0.875rem; color: #6c757d; margin-top: 0.25rem;">
63
+ Function: <code style="background: #e9ecef; padding: 0.125rem 0.25rem; border-radius: 2px;"><%= trigger[:function_name] %></code>
64
+ </div>
65
+ </div>
66
+ <% end %>
67
+ </div>
68
+ <% else %>
69
+ <span style="color: #6c757d; font-style: italic;">No triggers</span>
70
+ <% end %>
71
+ </td>
72
+ <td>
73
+ <% if table[:trigger_count] > 0 %>
74
+ <% enabled_count = table[:registry_triggers].count { |t| t[:enabled] } %>
75
+ <% disabled_count = table[:registry_triggers].count - enabled_count %>
76
+ <div style="display: flex; flex-direction: column; gap: 0.25rem;">
77
+ <% if enabled_count > 0 %>
78
+ <span class="badge badge-success" style="font-size: 0.75rem;"><%= enabled_count %> Enabled</span>
79
+ <% end %>
80
+ <% if disabled_count > 0 %>
81
+ <span class="badge badge-danger" style="font-size: 0.75rem;"><%= disabled_count %> Disabled</span>
82
+ <% end %>
83
+ </div>
84
+ <% else %>
85
+ <span style="color: #6c757d;">—</span>
86
+ <% end %>
87
+ </td>
88
+ <td>
89
+ <%= link_to "View Details", table_path(table[:table_name]), class: "btn btn-primary", style: "padding: 0.25rem 0.5rem; font-size: 0.875rem;" %>
90
+ <%= link_to "Create Trigger", new_generator_path(pg_sql_triggers_generator_form: { table_name: table[:table_name] }), class: "btn btn-success", style: "padding: 0.25rem 0.5rem; font-size: 0.875rem; margin-top: 0.25rem; display: block;" %>
91
+ </td>
92
+ </tr>
93
+ <% end %>
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ <% else %>
98
+ <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1.5rem; border-radius: 4px;">
99
+ <h3 style="margin-top: 0;">No tables with triggers found</h3>
100
+ <p style="margin-bottom: 1rem;">No tables with triggers were found in the database. Create your first trigger to get started.</p>
101
+ <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
102
+ </div>
103
+ <% end %>
104
+
105
+
@@ -0,0 +1,126 @@
1
+ <div style="margin-bottom: 2rem;">
2
+ <h2>Table: <%= @table_info[:table_name] %></h2>
3
+ <%= link_to "← Back to Tables", tables_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; margin-right: 1rem;" %>
4
+ <%= link_to "Create Trigger for this Table", new_generator_path(pg_sql_triggers_generator_form: { table_name: @table_info[:table_name] }), class: "btn btn-success" %>
5
+ </div>
6
+
7
+ <!-- Table Information -->
8
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
9
+ <h3 style="margin-top: 0;">Table Columns</h3>
10
+ <% if @columns.any? %>
11
+ <table>
12
+ <thead>
13
+ <tr>
14
+ <th>Column Name</th>
15
+ <th>Data Type</th>
16
+ <th>Nullable</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @columns.each do |column| %>
21
+ <tr>
22
+ <td><strong><%= column[:name] %></strong></td>
23
+ <td><code><%= column[:type] %></code></td>
24
+ <td>
25
+ <% if column[:nullable] %>
26
+ <span class="badge badge-warning">Yes</span>
27
+ <% else %>
28
+ <span class="badge badge-success">No</span>
29
+ <% end %>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ <% else %>
36
+ <p style="color: #6c757d;">No columns found.</p>
37
+ <% end %>
38
+ </div>
39
+
40
+ <!-- Registry Triggers -->
41
+ <div style="background: white; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
42
+ <h3 style="margin-top: 0;">Registered Triggers</h3>
43
+ <% if @table_info[:registry_triggers].any? %>
44
+ <div style="display: flex; flex-direction: column; gap: 1rem;">
45
+ <% @table_info[:registry_triggers].each do |trigger| %>
46
+ <div style="border: 1px solid #dee2e6; border-radius: 4px; padding: 1rem; background: #f8f9fa;">
47
+ <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
48
+ <div>
49
+ <h4 style="margin: 0; color: #007bff;">
50
+ <%= trigger.trigger_name %>
51
+ <% if trigger.enabled %>
52
+ <span class="badge badge-success">Enabled</span>
53
+ <% else %>
54
+ <span class="badge badge-danger">Disabled</span>
55
+ <% end %>
56
+ </h4>
57
+ <p style="color: #6c757d; margin: 0.5rem 0 0 0;">
58
+ Version: <strong><%= trigger.version %></strong> |
59
+ Source: <span class="badge badge-info"><%= trigger.source %></span>
60
+ <% if trigger.environment.present? %>
61
+ | Environment: <strong><%= trigger.environment %></strong>
62
+ <% end %>
63
+ </p>
64
+ </div>
65
+ </div>
66
+
67
+ <% if trigger.definition.present? %>
68
+ <% definition = JSON.parse(trigger.definition) rescue {} %>
69
+ <div style="margin-bottom: 1rem;">
70
+ <strong>Function:</strong> <code style="background: #e9ecef; padding: 0.25rem 0.5rem; border-radius: 2px;"><%= definition["function_name"] || "N/A" %></code>
71
+ <% if definition["events"].present? %>
72
+ <span style="margin-left: 1rem;">
73
+ <strong>Events:</strong>
74
+ <% definition["events"].each do |event| %>
75
+ <span class="badge badge-info" style="margin-left: 0.25rem;"><%= event %></span>
76
+ <% end %>
77
+ </span>
78
+ <% end %>
79
+ </div>
80
+ <% end %>
81
+
82
+ <% if trigger.function_body.present? %>
83
+ <details style="margin-top: 1rem;">
84
+ <summary style="cursor: pointer; font-weight: 600; color: #495057;">View Function Body</summary>
85
+ <pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-top: 0.5rem; overflow-x: auto;"><code><%= trigger.function_body %></code></pre>
86
+ </details>
87
+ <% end %>
88
+ </div>
89
+ <% end %>
90
+ </div>
91
+ <% else %>
92
+ <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; border-radius: 4px;">
93
+ <p style="margin: 0;">No registered triggers for this table.</p>
94
+ <%= link_to "Create a trigger", new_generator_path(pg_sql_triggers_generator_form: { table_name: @table_info[:table_name] }), class: "btn btn-primary", style: "margin-top: 0.5rem;" %>
95
+ </div>
96
+ <% end %>
97
+ </div>
98
+
99
+ <!-- Database Triggers (not in registry) -->
100
+ <% if @table_info[:database_triggers].any? %>
101
+ <div style="background: #fff3cd; padding: 1.5rem; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-left: 4px solid #ffc107;">
102
+ <h3 style="margin-top: 0; color: #856404;">Database Triggers (Not in Registry)</h3>
103
+ <p style="color: #856404; margin-bottom: 1rem;">
104
+ These triggers exist in the database but are not registered in the trigger registry.
105
+ Consider registering them or reviewing their status.
106
+ </p>
107
+ <div style="display: flex; flex-direction: column; gap: 1rem;">
108
+ <% @table_info[:database_triggers].each do |trigger| %>
109
+ <div style="border: 1px solid #ffc107; border-radius: 4px; padding: 1rem; background: white;">
110
+ <h4 style="margin: 0; color: #856404;">
111
+ <%= trigger[:trigger_name] %>
112
+ <span class="badge badge-warning">DB Only</span>
113
+ </h4>
114
+ <p style="color: #6c757d; margin: 0.5rem 0 0 0;">
115
+ Function: <code style="background: #e9ecef; padding: 0.125rem 0.25rem; border-radius: 2px;"><%= trigger[:function_name] %></code>
116
+ </p>
117
+ <details style="margin-top: 0.5rem;">
118
+ <summary style="cursor: pointer; font-weight: 600; color: #495057; font-size: 0.875rem;">View Definition</summary>
119
+ <pre style="background: #f8f9fa; padding: 1rem; border-radius: 4px; margin-top: 0.5rem; overflow-x: auto; font-size: 0.875rem;"><code><%= trigger[:definition] %></code></pre>
120
+ </details>
121
+ </div>
122
+ <% end %>
123
+ </div>
124
+ </div>
125
+ <% end %>
126
+
data/config/routes.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ PgSqlTriggers::Engine.routes.draw do
5
+ root to: "dashboard#index"
6
+ get "dashboard", to: "dashboard#index", as: "dashboard"
7
+
8
+ resources :tables, only: %i[index show]
9
+
10
+ resources :generator, only: %i[new create] do
11
+ collection do
12
+ post :preview
13
+ post :validate_table
14
+ get :tables
15
+ end
16
+ end
17
+
18
+ resources :sql_capsules, only: %i[new create show] do
19
+ member do
20
+ post :execute
21
+ end
22
+ end
23
+
24
+ resources :migrations, only: [] do
25
+ collection do
26
+ post :up
27
+ post :down
28
+ post :redo
29
+ end
30
+ end
31
+ end
32
+ rescue ArgumentError => e
33
+ # Ignore duplicate route errors (routes may already be drawn in tests)
34
+ raise unless e.message.include?("already in use")
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePgSqlTriggersTables < ActiveRecord::Migration[6.0]
4
+ def change
5
+ # Registry table - source of truth for all triggers
6
+ create_table :pg_sql_triggers_registry do |t|
7
+ t.string :trigger_name, null: false
8
+ t.string :table_name, null: false
9
+ t.integer :version, null: false, default: 1
10
+ t.boolean :enabled, null: false, default: false
11
+ t.string :checksum, null: false
12
+ t.string :source, null: false # dsl, generated, manual_sql
13
+ t.string :environment
14
+ t.text :definition # Stored DSL or SQL definition
15
+ t.text :function_body # The actual function body
16
+ t.text :condition # Optional WHEN clause condition
17
+ t.datetime :installed_at
18
+ t.datetime :last_verified_at
19
+
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :pg_sql_triggers_registry, :trigger_name, unique: true
24
+ add_index :pg_sql_triggers_registry, :table_name
25
+ add_index :pg_sql_triggers_registry, :enabled
26
+ add_index :pg_sql_triggers_registry, :source
27
+ add_index :pg_sql_triggers_registry, :environment
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module PgSqlTriggers
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ next_migration_number = current_migration_number(dirname) + 1
15
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
16
+ end
17
+
18
+ def copy_initializer
19
+ template "initializer.rb", "config/initializers/pg_sql_triggers.rb"
20
+ end
21
+
22
+ def copy_migrations
23
+ migration_template "create_pg_sql_triggers_tables.rb",
24
+ "db/migrate/create_pg_sql_triggers_tables.rb"
25
+ end
26
+
27
+ def mount_engine
28
+ route 'mount PgSqlTriggers::Engine => "/pg_sql_triggers"'
29
+ end
30
+
31
+ def show_readme
32
+ readme "README" if behavior == :invoke
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ ===============================================================================
2
+
3
+ PgSqlTriggers has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Run migrations:
8
+ $ rails db:migrate
9
+
10
+ 2. Mount the engine in your routes.rb (already done):
11
+ mount PgSqlTriggers::Engine => "/pg_sql_triggers"
12
+
13
+ 3. Configure the gem in config/initializers/pg_sql_triggers.rb
14
+
15
+ 4. Create your first trigger:
16
+
17
+ Option A: Using DSL (app/triggers/example_trigger.rb):
18
+ PgSqlTriggers::DSL.pg_sql_trigger "example_guard" do
19
+ table :users
20
+ on :insert, :update
21
+ function :validate_user_rules
22
+ version 1
23
+ enabled false
24
+ when_env :production
25
+ end
26
+
27
+ Option B: Using trigger migrations:
28
+ rails generate trigger:migration add_example_trigger
29
+ # Edit db/triggers/YYYYMMDDHHMMSS_add_example_trigger.rb
30
+ rake trigger:migrate
31
+
32
+ 5. Visit http://localhost:3000/pg_sql_triggers to access the UI
33
+
34
+ For more information, see: https://github.com/samaswin87/pg_sql_triggers
35
+
36
+ ===============================================================================
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePgSqlTriggersTables < ActiveRecord::Migration[6.0]
4
+ def change
5
+ # Registry table - source of truth for all triggers
6
+ create_table :pg_sql_triggers_registry do |t|
7
+ t.string :trigger_name, null: false
8
+ t.string :table_name, null: false
9
+ t.integer :version, null: false, default: 1
10
+ t.boolean :enabled, null: false, default: false
11
+ t.string :checksum, null: false
12
+ t.string :source, null: false # dsl, generated, manual_sql
13
+ t.string :environment
14
+ t.text :definition # Stored DSL or SQL definition
15
+ t.text :function_body # The actual function body
16
+ t.text :condition # Optional WHEN clause condition
17
+ t.datetime :installed_at
18
+ t.datetime :last_verified_at
19
+
20
+ t.timestamps
21
+ end
22
+
23
+ add_index :pg_sql_triggers_registry, :trigger_name, unique: true
24
+ add_index :pg_sql_triggers_registry, :table_name
25
+ add_index :pg_sql_triggers_registry, :enabled
26
+ add_index :pg_sql_triggers_registry, :source
27
+ add_index :pg_sql_triggers_registry, :environment
28
+
29
+ # Trigger migrations table - tracks which trigger migrations have been run
30
+ create_table :trigger_migrations do |t|
31
+ t.string :version, null: false
32
+ end
33
+
34
+ add_index :trigger_migrations, :version, unique: true
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ PgSqlTriggers.configure do |config|
4
+ # Enable or disable the production kill switch
5
+ # When enabled, all destructive operations in production require explicit confirmation
6
+ config.kill_switch_enabled = true
7
+
8
+ # Set the default environment detection
9
+ # By default, uses Rails.env
10
+ config.default_environment = -> { Rails.env }
11
+
12
+ # Set a custom permission checker
13
+ # This should return true/false based on the actor, action, and environment
14
+ # Example:
15
+ # config.permission_checker = ->(actor, action, environment) {
16
+ # # Your custom permission logic here
17
+ # # e.g., check if actor has required role for the action
18
+ # true
19
+ # }
20
+ config.permission_checker = nil
21
+
22
+ # Tables to exclude from listing in the UI
23
+ # Default excluded tables: ar_internal_metadata, schema_migrations, pg_sql_triggers_registry, trigger_migrations
24
+ # Add additional tables you want to exclude:
25
+ # config.excluded_tables = %w[audit_logs temporary_data]
26
+ config.excluded_tables = []
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < PgSqlTriggers::Migration
4
+ def up
5
+ # Add your trigger migration code here
6
+ # Example:
7
+ # execute <<-SQL
8
+ # CREATE OR REPLACE FUNCTION my_function()
9
+ # RETURNS TRIGGER AS $$
10
+ # BEGIN
11
+ # -- Your trigger logic here
12
+ # RETURN NEW;
13
+ # END;
14
+ # $$ LANGUAGE plpgsql;
15
+ #
16
+ # CREATE TRIGGER my_trigger
17
+ # BEFORE INSERT ON my_table
18
+ # FOR EACH ROW
19
+ # EXECUTE FUNCTION my_function();
20
+ # SQL
21
+ end
22
+
23
+ def down
24
+ # Add your rollback code here
25
+ # Example:
26
+ # execute <<-SQL
27
+ # DROP TRIGGER IF EXISTS my_trigger ON my_table;
28
+ # DROP FUNCTION IF EXISTS my_function();
29
+ # SQL
30
+ end
31
+ end
32
+
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module PgSqlTriggers
8
+ module Generators
9
+ class TriggerMigrationGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ argument :name, type: :string, desc: "Name of the trigger migration"
15
+
16
+ def self.next_migration_number(_dirname)
17
+ # Get the highest migration number from existing migrations
18
+ existing = if Rails.root.join("db/triggers").exist?
19
+ Rails.root.glob("db/triggers/*.rb")
20
+ .map { |f| File.basename(f, ".rb").split("_").first.to_i }
21
+ .reject(&:zero?)
22
+ .max || 0
23
+ else
24
+ 0
25
+ end
26
+
27
+ # Generate next timestamp-based version
28
+ # Format: YYYYMMDDHHMMSS
29
+ now = Time.now.utc
30
+ base = now.strftime("%Y%m%d%H%M%S").to_i
31
+
32
+ # If we have existing migrations, ensure we're incrementing
33
+ base = existing + 1 if existing.positive? && base <= existing
34
+
35
+ base
36
+ end
37
+
38
+ def create_trigger_migration
39
+ migration_template(
40
+ "trigger_migration.rb.erb",
41
+ "db/triggers/#{file_name}.rb"
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def file_name
48
+ "#{migration_number}_#{name.underscore}"
49
+ end
50
+
51
+ def migration_number
52
+ self.class.next_migration_number(nil)
53
+ end
54
+
55
+ def class_name
56
+ name.camelize
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module Trigger
8
+ module Generators
9
+ class MigrationGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("../../pg_sql_triggers/templates", __dir__)
13
+
14
+ argument :name, type: :string, desc: "Name of the trigger migration"
15
+
16
+ def self.next_migration_number(_dirname)
17
+ # Get the highest migration number from existing migrations
18
+ existing = if Rails.root.join("db/triggers").exist?
19
+ Rails.root.glob("db/triggers/*.rb")
20
+ .map { |f| File.basename(f, ".rb").split("_").first.to_i }
21
+ .reject(&:zero?)
22
+ .max || 0
23
+ else
24
+ 0
25
+ end
26
+
27
+ # Generate next timestamp-based version
28
+ # Format: YYYYMMDDHHMMSS
29
+ now = Time.now.utc
30
+ base = now.strftime("%Y%m%d%H%M%S").to_i
31
+
32
+ # If we have existing migrations, ensure we're incrementing
33
+ base = existing + 1 if existing.positive? && base <= existing
34
+
35
+ base
36
+ end
37
+
38
+ def create_trigger_migration
39
+ migration_template(
40
+ "trigger_migration.rb.erb",
41
+ "db/triggers/#{file_name}.rb"
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def file_name
48
+ "#{migration_number}_#{name.underscore}"
49
+ end
50
+
51
+ def class_name
52
+ name.camelize
53
+ end
54
+
55
+ def migration_number
56
+ self.class.next_migration_number(nil)
57
+ end
58
+ end
59
+ end
60
+ end