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,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ class DatabaseIntrospection
5
+ # Default tables to exclude from listing (Rails defaults and pg_sql_triggers internal tables)
6
+ DEFAULT_EXCLUDED_TABLES = %w[
7
+ ar_internal_metadata
8
+ schema_migrations
9
+ pg_sql_triggers_registry
10
+ trigger_migrations
11
+ ].freeze
12
+
13
+ # Get list of all excluded tables (defaults + user-configured)
14
+ def excluded_tables
15
+ (DEFAULT_EXCLUDED_TABLES + Array(PgSqlTriggers.excluded_tables)).uniq
16
+ end
17
+
18
+ # Get list of all user tables in the database
19
+ def list_tables
20
+ sql = <<~SQL.squish
21
+ SELECT table_name
22
+ FROM information_schema.tables
23
+ WHERE table_schema = 'public'
24
+ AND table_type = 'BASE TABLE'
25
+ ORDER BY table_name
26
+ SQL
27
+
28
+ result = ActiveRecord::Base.connection.execute(sql)
29
+ tables = result.pluck("table_name")
30
+ tables.reject { |table| excluded_tables.include?(table) }
31
+ rescue StandardError => e
32
+ Rails.logger.error("Failed to fetch tables: #{e.message}") if defined?(Rails.logger)
33
+ []
34
+ end
35
+
36
+ # Validate that a table exists
37
+ def validate_table(table_name)
38
+ return { valid: false, error: "Table name cannot be blank" } if table_name.blank?
39
+
40
+ # Use case-insensitive comparison and sanitize input
41
+ sanitized_name = sanitize(table_name)
42
+
43
+ # First, check if table exists and get column count
44
+ column_count_sql = <<~SQL.squish
45
+ SELECT COUNT(*) as column_count
46
+ FROM information_schema.columns
47
+ WHERE table_schema = 'public'
48
+ AND LOWER(table_name) = LOWER('#{sanitized_name}')
49
+ SQL
50
+
51
+ column_result = ActiveRecord::Base.connection.execute(column_count_sql).first
52
+ column_count = column_result ? column_result["column_count"].to_i : 0
53
+
54
+ if column_count.positive?
55
+ # Get table comment separately
56
+ comment_sql = <<~SQL.squish
57
+ SELECT obj_description(c.oid, 'pg_class') as comment
58
+ FROM pg_class c
59
+ JOIN pg_namespace n ON n.oid = c.relnamespace
60
+ WHERE n.nspname = 'public'
61
+ AND LOWER(c.relname) = LOWER('#{sanitized_name}')
62
+ AND c.relkind = 'r'
63
+ SQL
64
+
65
+ comment_result = ActiveRecord::Base.connection.execute(comment_sql).first
66
+ comment = comment_result ? comment_result["comment"] : nil
67
+
68
+ {
69
+ valid: true,
70
+ table_name: table_name,
71
+ column_count: column_count,
72
+ comment: comment
73
+ }
74
+ else
75
+ {
76
+ valid: false,
77
+ error: "Table '#{table_name}' not found in database"
78
+ }
79
+ end
80
+ rescue StandardError => e
81
+ Rails.logger.error("Table validation error for '#{table_name}': #{e.message}")
82
+ {
83
+ valid: false,
84
+ error: e.message
85
+ }
86
+ end
87
+
88
+ # Get table columns
89
+ def table_columns(table_name)
90
+ sql = <<~SQL.squish
91
+ SELECT column_name, data_type, is_nullable
92
+ FROM information_schema.columns
93
+ WHERE table_schema = 'public'
94
+ AND table_name = '#{sanitize(table_name)}'
95
+ ORDER BY ordinal_position
96
+ SQL
97
+
98
+ result = ActiveRecord::Base.connection.execute(sql)
99
+ result.map do |row|
100
+ {
101
+ name: row["column_name"],
102
+ type: row["data_type"],
103
+ nullable: row["is_nullable"] == "YES"
104
+ }
105
+ end
106
+ end
107
+
108
+ # Check if function exists
109
+ def function_exists?(function_name)
110
+ sql = <<~SQL.squish
111
+ SELECT COUNT(*) as count
112
+ FROM pg_proc
113
+ WHERE proname = '#{sanitize(function_name)}'
114
+ SQL
115
+
116
+ result = ActiveRecord::Base.connection.execute(sql).first
117
+ result["count"].to_i.positive?
118
+ end
119
+
120
+ # Check if trigger exists
121
+ def trigger_exists?(trigger_name)
122
+ sql = <<~SQL.squish
123
+ SELECT COUNT(*) as count
124
+ FROM pg_trigger t
125
+ JOIN pg_class c ON t.tgrelid = c.oid
126
+ JOIN pg_namespace n ON c.relnamespace = n.oid
127
+ WHERE t.tgname = '#{sanitize(trigger_name)}'
128
+ AND n.nspname = 'public'
129
+ AND NOT t.tgisinternal
130
+ SQL
131
+
132
+ result = ActiveRecord::Base.connection.execute(sql).first
133
+ result["count"].to_i.positive?
134
+ rescue StandardError => e
135
+ Rails.logger.error("Failed to check if trigger exists: #{e.message}") if defined?(Rails.logger)
136
+ false
137
+ end
138
+
139
+ # Get all tables with their triggers and functions
140
+ def tables_with_triggers
141
+ # Get all tables
142
+ tables = list_tables
143
+
144
+ # Get all triggers from registry
145
+ triggers_by_table = PgSqlTriggers::TriggerRegistry.all.group_by(&:table_name)
146
+
147
+ # Get actual database triggers
148
+ db_triggers_sql = <<~SQL.squish
149
+ SELECT#{' '}
150
+ t.tgname as trigger_name,
151
+ c.relname as table_name,
152
+ p.proname as function_name,
153
+ pg_get_triggerdef(t.oid) as trigger_definition
154
+ FROM pg_trigger t
155
+ JOIN pg_class c ON t.tgrelid = c.oid
156
+ JOIN pg_proc p ON t.tgfoid = p.oid
157
+ JOIN pg_namespace n ON c.relnamespace = n.oid
158
+ WHERE NOT t.tgisinternal
159
+ AND n.nspname = 'public'
160
+ ORDER BY c.relname, t.tgname
161
+ SQL
162
+
163
+ db_triggers = {}
164
+ begin
165
+ result = ActiveRecord::Base.connection.execute(db_triggers_sql)
166
+ result.each do |row|
167
+ table_name = row["table_name"]
168
+ db_triggers[table_name] ||= []
169
+ db_triggers[table_name] << {
170
+ trigger_name: row["trigger_name"],
171
+ function_name: row["function_name"],
172
+ definition: row["trigger_definition"]
173
+ }
174
+ end
175
+ rescue StandardError => e
176
+ Rails.logger.error("Failed to fetch database triggers: #{e.message}")
177
+ end
178
+
179
+ # Combine registry and database triggers
180
+ tables.map do |table_name|
181
+ registry_triggers = triggers_by_table[table_name] || []
182
+ db_table_triggers = db_triggers[table_name] || []
183
+
184
+ {
185
+ table_name: table_name,
186
+ registry_triggers: registry_triggers.map do |t|
187
+ {
188
+ id: t.id,
189
+ trigger_name: t.trigger_name,
190
+ function_name: t.definition.present? ? JSON.parse(t.definition)["function_name"] : nil,
191
+ enabled: t.enabled,
192
+ version: t.version,
193
+ source: t.source,
194
+ function_body: t.function_body
195
+ }
196
+ end,
197
+ database_triggers: db_table_triggers,
198
+ trigger_count: registry_triggers.count + db_table_triggers.count
199
+ }
200
+ end
201
+ end
202
+
203
+ # Get triggers for a specific table
204
+ def table_triggers(table_name)
205
+ # From registry
206
+ registry_triggers = PgSqlTriggers::TriggerRegistry.for_table(table_name)
207
+
208
+ # From database
209
+ db_triggers_sql = <<~SQL.squish
210
+ SELECT#{' '}
211
+ t.tgname as trigger_name,
212
+ p.proname as function_name,
213
+ pg_get_triggerdef(t.oid) as trigger_definition
214
+ FROM pg_trigger t
215
+ JOIN pg_class c ON t.tgrelid = c.oid
216
+ JOIN pg_proc p ON t.tgfoid = p.oid
217
+ JOIN pg_namespace n ON c.relnamespace = n.oid
218
+ WHERE NOT t.tgisinternal
219
+ AND c.relname = '#{sanitize(table_name)}'
220
+ AND n.nspname = 'public'
221
+ ORDER BY t.tgname
222
+ SQL
223
+
224
+ db_triggers = []
225
+ begin
226
+ result = ActiveRecord::Base.connection.execute(db_triggers_sql)
227
+ result.each do |row|
228
+ db_triggers << {
229
+ trigger_name: row["trigger_name"],
230
+ function_name: row["function_name"],
231
+ definition: row["trigger_definition"]
232
+ }
233
+ end
234
+ rescue StandardError => e
235
+ Rails.logger.error("Failed to fetch database triggers: #{e.message}")
236
+ end
237
+
238
+ {
239
+ table_name: table_name,
240
+ registry_triggers: registry_triggers,
241
+ database_triggers: db_triggers
242
+ }
243
+ end
244
+
245
+ private
246
+
247
+ def sanitize(value)
248
+ ActiveRecord::Base.connection.quote_string(value.to_s)
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Drift
5
+ autoload :Detector, "pg_sql_triggers/drift/detector"
6
+ autoload :Reporter, "pg_sql_triggers/drift/reporter"
7
+
8
+ # Drift states
9
+ MANAGED_IN_SYNC = "managed_in_sync"
10
+ MANAGED_DRIFTED = "managed_drifted"
11
+ MANUAL_OVERRIDE = "manual_override"
12
+ DISABLED = "disabled"
13
+ DROPPED = "dropped"
14
+ UNKNOWN = "unknown"
15
+
16
+ def self.detect(trigger_name = nil)
17
+ Detector.detect(trigger_name)
18
+ end
19
+
20
+ def self.report
21
+ Reporter.report
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module DSL
5
+ class TriggerDefinition
6
+ attr_accessor :name, :table_name, :events, :function_name, :environments, :condition
7
+
8
+ def initialize(name)
9
+ @name = name
10
+ @events = []
11
+ @version = 1
12
+ @enabled = false
13
+ @environments = []
14
+ @condition = nil
15
+ end
16
+
17
+ def table(table_name)
18
+ @table_name = table_name
19
+ end
20
+
21
+ def on(*events)
22
+ @events = events.map(&:to_s)
23
+ end
24
+
25
+ def function(function_name)
26
+ @function_name = function_name
27
+ end
28
+
29
+ def version(version = nil)
30
+ if version.nil?
31
+ @version
32
+ else
33
+ @version = version
34
+ end
35
+ end
36
+
37
+ def enabled(enabled = nil)
38
+ if enabled.nil?
39
+ @enabled
40
+ else
41
+ @enabled = enabled
42
+ end
43
+ end
44
+
45
+ def when_env(*environments)
46
+ @environments = environments.map(&:to_s)
47
+ end
48
+
49
+ def when_condition(condition_sql)
50
+ @condition = condition_sql
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ name: @name,
56
+ table_name: @table_name,
57
+ events: @events,
58
+ function_name: @function_name,
59
+ version: @version,
60
+ enabled: @enabled,
61
+ environments: @environments,
62
+ condition: @condition
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module DSL
5
+ autoload :TriggerDefinition, "pg_sql_triggers/dsl/trigger_definition"
6
+ autoload :Builder, "pg_sql_triggers/dsl/builder"
7
+
8
+ def self.pg_sql_trigger(name, &block)
9
+ definition = TriggerDefinition.new(name)
10
+ definition.instance_eval(&block)
11
+ Registry.register(definition)
12
+ definition
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace PgSqlTriggers
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ g.fixture_replacement :factory_bot
10
+ g.factory_bot dir: "spec/factories"
11
+ end
12
+
13
+ # Configure assets
14
+ initializer "pg_sql_triggers.assets" do |app|
15
+ # Rails engines automatically add app/assets to paths, but we explicitly add
16
+ # the stylesheets and javascripts directories to ensure they're found
17
+ if app.config.respond_to?(:assets)
18
+ app.config.assets.paths << root.join("app/assets/stylesheets").to_s
19
+ app.config.assets.paths << root.join("app/assets/javascripts").to_s
20
+ app.config.assets.precompile += %w[pg_sql_triggers/application.css pg_sql_triggers/application.js]
21
+ end
22
+ end
23
+
24
+ # Load rake tasks
25
+ rake_tasks do
26
+ load root.join("lib/tasks/trigger_migrations.rake")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Generator
5
+ class Form
6
+ include ActiveModel::Model
7
+
8
+ attr_accessor :trigger_name, :table_name, :function_name,
9
+ :version, :enabled, :condition,
10
+ :generate_function_stub, :events, :environments,
11
+ :function_body
12
+
13
+ validates :trigger_name, presence: true,
14
+ format: {
15
+ with: /\A[a-z0-9_]+\z/,
16
+ message: "must contain only lowercase letters, numbers, and underscores"
17
+ }
18
+ validates :table_name, presence: true
19
+ validates :function_name, presence: true,
20
+ format: {
21
+ with: /\A[a-z0-9_]+\z/,
22
+ message: "must contain only lowercase letters, numbers, and underscores"
23
+ }
24
+ validates :version, presence: true, numericality: { only_integer: true, greater_than: 0 }
25
+ validates :function_body, presence: true
26
+ validate :at_least_one_event
27
+ validate :function_name_matches_body
28
+
29
+ def initialize(attributes = {})
30
+ super
31
+ @version ||= 1
32
+ # Convert enabled to boolean (Rails checkboxes send "0" or "1" as strings)
33
+ # Default to true for UI-generated triggers
34
+ @enabled = case @enabled
35
+ when false, "0", 0 then false
36
+ else true # true, "1", 1, or nil - default to true
37
+ end
38
+ @generate_function_stub = true if @generate_function_stub.nil?
39
+ @events ||= []
40
+ @environments ||= []
41
+ end
42
+
43
+ def default_function_body
44
+ func_name = function_name.presence || "function_name"
45
+ <<~SQL.chomp
46
+ CREATE OR REPLACE FUNCTION #{func_name}()
47
+ RETURNS TRIGGER AS $$
48
+ BEGIN
49
+ -- Your trigger logic here
50
+ RETURN NEW;
51
+ END;
52
+ $$ LANGUAGE plpgsql;
53
+ SQL
54
+ end
55
+
56
+ private
57
+
58
+ def at_least_one_event
59
+ return unless events.blank? || events.compact_blank.empty?
60
+
61
+ errors.add(:events, "must include at least one event")
62
+ end
63
+
64
+ def function_name_matches_body
65
+ return if function_name.blank? || function_body.blank?
66
+
67
+ # Check if function_body contains the function_name in a CREATE FUNCTION statement
68
+ # Look for pattern: CREATE [OR REPLACE] FUNCTION function_name
69
+ function_pattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:[^(\s]+\.)?#{Regexp.escape(function_name)}\s*\(/i
70
+ return if function_body.match?(function_pattern)
71
+
72
+ expected_msg = "should define function '#{function_name}' " \
73
+ "(expected: CREATE [OR REPLACE] FUNCTION #{function_name}(...)"
74
+ errors.add(:function_body, expected_msg)
75
+ end
76
+ end
77
+ end
78
+ end