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
+ require "fileutils"
4
+ require "digest"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module PgSqlTriggers
8
+ module Generator
9
+ class Service
10
+ class << self
11
+ def generate_dsl(form)
12
+ # Generate DSL trigger definition
13
+ events_list = form.events.compact_blank.map { |e| ":#{e}" }.join(", ")
14
+ environments_list = form.environments.compact_blank.map { |e| ":#{e}" }.join(", ")
15
+
16
+ # Format function name (can be string or symbol)
17
+ function_ref = if form.function_name.to_s.match?(/\A[a-z0-9_]+\z/)
18
+ ":#{form.function_name}"
19
+ else
20
+ "\"#{form.function_name}\""
21
+ end
22
+
23
+ code = <<~RUBY
24
+ # frozen_string_literal: true
25
+
26
+ # Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
27
+ PgSqlTriggers::DSL.pg_sql_trigger "#{form.trigger_name}" do
28
+ table :#{form.table_name}
29
+ on #{events_list}
30
+ function #{function_ref}
31
+ #{' '}
32
+ version #{form.version}
33
+ enabled #{form.enabled}
34
+ RUBY
35
+
36
+ code += " when_env #{environments_list}\n" if form.environments.compact_blank.any?
37
+
38
+ code += " when_condition \"#{form.condition.gsub('"', '\\"')}\"\n" if form.condition.present?
39
+
40
+ code += "end\n"
41
+
42
+ code
43
+ end
44
+
45
+ def generate_migration(form)
46
+ # Generate migration class code
47
+ # Use Rails migration naming convention: Add{TriggerName}
48
+ # This avoids Zeitwerk autoload conflicts with app/triggers files
49
+ class_name = "Add#{form.trigger_name.camelize}"
50
+ events_sql = form.events.compact_blank.map(&:upcase).join(" OR ")
51
+ function_body_sql = form.function_body.strip
52
+
53
+ # Build the trigger creation SQL
54
+ trigger_sql = "CREATE TRIGGER #{form.trigger_name}\n"
55
+ trigger_sql += "BEFORE #{events_sql} ON #{form.table_name}\n"
56
+ trigger_sql += "FOR EACH ROW\n"
57
+ trigger_sql += "WHEN (#{form.condition})\n" if form.condition.present?
58
+ trigger_sql += "EXECUTE FUNCTION #{form.function_name}();"
59
+
60
+ # Build the down method SQL
61
+ down_sql = "DROP TRIGGER IF EXISTS #{form.trigger_name} ON #{form.table_name};\n"
62
+ down_sql += "DROP FUNCTION IF EXISTS #{form.function_name}();"
63
+
64
+ <<~RUBY
65
+ # frozen_string_literal: true
66
+
67
+ # Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
68
+ class #{class_name} < PgSqlTriggers::Migration
69
+ def up
70
+ # Create the function
71
+ execute <<-SQL
72
+ #{function_body_sql}
73
+ SQL
74
+
75
+ # Create the trigger
76
+ execute <<-SQL
77
+ #{trigger_sql}
78
+ SQL
79
+ end
80
+
81
+ def down
82
+ execute <<-SQL
83
+ #{down_sql}
84
+ SQL
85
+ end
86
+ end
87
+ RUBY
88
+ end
89
+
90
+ def generate_function_stub(form)
91
+ return nil unless form.generate_function_stub
92
+
93
+ # Generate PL/pgSQL function template
94
+ events_display = form.events.compact_blank.join(", ").upcase
95
+
96
+ <<~SQL
97
+ -- PL/pgSQL function for trigger: #{form.trigger_name}
98
+ -- Table: #{form.table_name}
99
+ -- Events: #{events_display}
100
+ -- Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
101
+ --
102
+ -- TODO: Implement your trigger logic below
103
+
104
+ CREATE OR REPLACE FUNCTION #{form.function_name}()
105
+ RETURNS TRIGGER AS $$
106
+ BEGIN
107
+ -- Access OLD record: OLD.column_name (for UPDATE, DELETE)
108
+ -- Access NEW record: NEW.column_name (for INSERT, UPDATE)
109
+
110
+ -- Example: Validate a field
111
+ -- IF NEW.some_field > threshold THEN
112
+ -- RAISE EXCEPTION 'Validation failed: %', NEW.some_field;
113
+ -- END IF;
114
+
115
+ -- TODO: Add your validation/business logic here
116
+ RAISE NOTICE 'Trigger function % called for %', TG_NAME, TG_OP;
117
+
118
+ -- For INSERT/UPDATE triggers, return NEW
119
+ -- For DELETE triggers, return OLD
120
+ -- For TRUNCATE triggers, return NULL
121
+ IF TG_OP = 'DELETE' THEN
122
+ RETURN OLD;
123
+ ELSIF TG_OP = 'TRUNCATE' THEN
124
+ RETURN NULL;
125
+ ELSE
126
+ RETURN NEW;
127
+ END IF;
128
+ END;
129
+ $$ LANGUAGE plpgsql;
130
+ SQL
131
+ end
132
+
133
+ def file_paths(form)
134
+ # NOTE: These paths are relative to the host Rails app, not the gem
135
+ # Generate both migration file and DSL file
136
+ migration_version = next_migration_number
137
+ {
138
+ migration: "db/triggers/#{migration_version}_#{form.trigger_name}.rb",
139
+ dsl: "app/triggers/#{form.trigger_name}.rb"
140
+ }
141
+ end
142
+
143
+ def create_trigger(form, _actor: nil)
144
+ paths = file_paths(form)
145
+
146
+ # Determine if we're in a Rails app context or standalone gem
147
+ base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
148
+
149
+ full_migration_path = base_path.join(paths[:migration])
150
+ full_dsl_path = base_path.join(paths[:dsl])
151
+
152
+ # Create directories
153
+ FileUtils.mkdir_p(full_migration_path.dirname)
154
+ FileUtils.mkdir_p(full_dsl_path.dirname)
155
+
156
+ # Generate content
157
+ migration_content = generate_migration(form)
158
+ dsl_content = generate_dsl(form)
159
+ # Use function_body (required field)
160
+ function_content = form.function_body
161
+
162
+ # Write both files
163
+ File.write(full_migration_path, migration_content)
164
+ File.write(full_dsl_path, dsl_content)
165
+
166
+ # Register in TriggerRegistry
167
+ definition = {
168
+ name: form.trigger_name,
169
+ table_name: form.table_name,
170
+ events: form.events.compact_blank,
171
+ function_name: form.function_name,
172
+ version: form.version,
173
+ enabled: form.enabled,
174
+ environments: form.environments.compact_blank,
175
+ condition: form.condition
176
+ }
177
+
178
+ attributes = {
179
+ trigger_name: form.trigger_name,
180
+ table_name: form.table_name,
181
+ version: form.version,
182
+ enabled: form.enabled,
183
+ source: "dsl",
184
+ environment: form.environments.compact_blank.join(",").presence,
185
+ definition: definition.to_json,
186
+ function_body: function_content,
187
+ checksum: calculate_checksum(definition)
188
+ }
189
+
190
+ # Only include condition if the column exists and value is present
191
+ attributes[:condition] = form.condition.presence if TriggerRegistry.column_names.include?("condition")
192
+
193
+ registry = TriggerRegistry.create!(attributes)
194
+
195
+ {
196
+ success: true,
197
+ registry_id: registry.id,
198
+ migration_path: paths[:migration],
199
+ dsl_path: paths[:dsl],
200
+ metadata: {
201
+ trigger_name: form.trigger_name,
202
+ table_name: form.table_name,
203
+ events: form.events.compact_blank,
204
+ files_created: [paths[:migration], paths[:dsl]]
205
+ }
206
+ }
207
+ rescue StandardError => e
208
+ Rails.logger.error("Trigger generation failed: #{e.message}") if defined?(Rails)
209
+ Rails.logger.error(e.backtrace.join("\n")) if defined?(Rails)
210
+
211
+ {
212
+ success: false,
213
+ error: e.message
214
+ }
215
+ end
216
+
217
+ private
218
+
219
+ def next_migration_number
220
+ # Determine if we're in a Rails app context or standalone gem
221
+ base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
222
+ triggers_path = base_path.join("db", "triggers")
223
+
224
+ # Get the highest migration number from existing migrations
225
+ existing = if Dir.exist?(triggers_path)
226
+ Dir.glob(triggers_path.join("*.rb"))
227
+ .map { |f| File.basename(f, ".rb").split("_").first.to_i }
228
+ .reject(&:zero?)
229
+ .max || 0
230
+ else
231
+ 0
232
+ end
233
+
234
+ # Generate next timestamp-based version
235
+ # Format: YYYYMMDDHHMMSS
236
+ now = Time.now.utc
237
+ base = now.strftime("%Y%m%d%H%M%S").to_i
238
+
239
+ # If we have existing migrations, ensure we're incrementing
240
+ base = existing + 1 if existing.positive? && base <= existing
241
+
242
+ base
243
+ end
244
+
245
+ def calculate_checksum(definition)
246
+ Digest::SHA256.hexdigest(definition.to_json)
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Generator
5
+ autoload :Service, "pg_sql_triggers/generator/service"
6
+ autoload :Form, "pg_sql_triggers/generator/form"
7
+ end
8
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ class Migration < ActiveRecord::Migration[6.0]
5
+ # Base class for trigger migrations
6
+ # Similar to ActiveRecord::Migration but for trigger-specific migrations
7
+
8
+ # rubocop:disable Rails/Delegate
9
+ # delegate doesn't work here due to argument forwarding issues in this context
10
+ def execute(sql)
11
+ connection.execute(sql)
12
+ end
13
+ # rubocop:enable Rails/Delegate
14
+ end
15
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "active_support/core_ext/module/delegation"
6
+
7
+ module PgSqlTriggers
8
+ class Migrator
9
+ MIGRATIONS_TABLE_NAME = "trigger_migrations"
10
+
11
+ class << self
12
+ def migrations_path
13
+ Rails.root.join("db/triggers")
14
+ end
15
+
16
+ def migrations_table_exists?
17
+ ActiveRecord::Base.connection.table_exists?(MIGRATIONS_TABLE_NAME)
18
+ end
19
+
20
+ def ensure_migrations_table!
21
+ return if migrations_table_exists?
22
+
23
+ ActiveRecord::Base.connection.create_table MIGRATIONS_TABLE_NAME do |t|
24
+ t.string :version, null: false
25
+ end
26
+
27
+ ActiveRecord::Base.connection.add_index MIGRATIONS_TABLE_NAME, :version, unique: true
28
+ end
29
+
30
+ def current_version
31
+ ensure_migrations_table!
32
+ result = ActiveRecord::Base.connection.select_one(
33
+ "SELECT version FROM #{MIGRATIONS_TABLE_NAME} ORDER BY version DESC LIMIT 1"
34
+ )
35
+ result ? result["version"].to_i : 0
36
+ end
37
+
38
+ def migrations
39
+ return [] unless Dir.exist?(migrations_path)
40
+
41
+ files = Dir.glob(migrations_path.join("*.rb")).sort
42
+ files.map do |file|
43
+ basename = File.basename(file, ".rb")
44
+ # Handle Rails migration format: YYYYMMDDHHMMSS_name
45
+ # Extract version (timestamp) and name
46
+ if basename =~ /^(\d+)_(.+)$/
47
+ version = ::Regexp.last_match(1).to_i
48
+ name = ::Regexp.last_match(2)
49
+ else
50
+ # Fallback: treat first part as version
51
+ parts = basename.split("_", 2)
52
+ version = parts[0].to_i
53
+ name = parts[1] || basename
54
+ end
55
+
56
+ Struct.new(:version, :name, :filename, :path, keyword_init: true).new(
57
+ version: version,
58
+ name: name,
59
+ filename: File.basename(file),
60
+ path: file
61
+ )
62
+ end
63
+ end
64
+
65
+ def pending_migrations
66
+ current_ver = current_version
67
+ migrations.select { |m| m.version > current_ver }
68
+ end
69
+
70
+ def run(direction = :up, target_version = nil)
71
+ ensure_migrations_table!
72
+
73
+ case direction
74
+ when :up
75
+ run_up(target_version)
76
+ when :down
77
+ run_down(target_version)
78
+ end
79
+ end
80
+
81
+ def run_up(target_version = nil)
82
+ if target_version
83
+ # Apply a specific migration version
84
+ migration_to_apply = migrations.find { |m| m.version == target_version }
85
+ raise StandardError, "Migration version #{target_version} not found" if migration_to_apply.nil?
86
+
87
+ # Check if it's already applied
88
+ quoted_version = ActiveRecord::Base.connection.quote(target_version.to_s)
89
+ version_exists = ActiveRecord::Base.connection.select_value(
90
+ "SELECT 1 FROM #{MIGRATIONS_TABLE_NAME} WHERE version = #{quoted_version} LIMIT 1"
91
+ )
92
+
93
+ raise StandardError, "Migration version #{target_version} is already applied" if version_exists.present?
94
+
95
+ run_migration(migration_to_apply, :up)
96
+ else
97
+ # Apply all pending migrations
98
+ pending = pending_migrations
99
+ pending.each do |migration|
100
+ run_migration(migration, :up)
101
+ end
102
+ end
103
+ end
104
+
105
+ def run_down(target_version = nil)
106
+ current_ver = current_version
107
+ return if current_ver.zero?
108
+
109
+ if target_version
110
+ # Rollback to the specified version (rollback all migrations with version > target_version)
111
+ target_migration = migrations.find { |m| m.version == target_version }
112
+
113
+ raise StandardError, "Migration version #{target_version} not found or not applied" if target_migration.nil?
114
+
115
+ if current_ver <= target_version
116
+ raise StandardError, "Migration version #{target_version} not found or not applied"
117
+ end
118
+
119
+ migrations_to_rollback = migrations
120
+ .select { |m| m.version > target_version && m.version <= current_ver }
121
+ .sort_by(&:version)
122
+ .reverse
123
+
124
+ else
125
+ # Rollback the last migration by default
126
+ migrations_to_rollback = migrations
127
+ .select { |m| m.version == current_ver }
128
+ .sort_by(&:version)
129
+ .reverse
130
+
131
+ end
132
+ migrations_to_rollback.each do |migration|
133
+ run_migration(migration, :down)
134
+ end
135
+ end
136
+
137
+ def run_migration(migration, direction)
138
+ require migration.path
139
+
140
+ # Extract class name from migration name
141
+ # e.g., "posts_comment_count_validation" -> "PostsCommentCountValidation"
142
+ base_class_name = migration.name.camelize
143
+
144
+ # Try to find the class, trying multiple patterns:
145
+ # 1. Direct name (for backwards compatibility)
146
+ # 2. With "Add" prefix (for new migrations following Rails conventions)
147
+ # 3. With PgSqlTriggers namespace
148
+ migration_class = begin
149
+ base_class_name.constantize
150
+ rescue NameError
151
+ begin
152
+ # Try with "Add" prefix (Rails migration naming convention)
153
+ "Add#{base_class_name}".constantize
154
+ rescue NameError
155
+ begin
156
+ # Try with PgSqlTriggers namespace
157
+ "PgSqlTriggers::#{base_class_name}".constantize
158
+ rescue NameError
159
+ # Try with both Add prefix and PgSqlTriggers namespace
160
+ "PgSqlTriggers::Add#{base_class_name}".constantize
161
+ end
162
+ end
163
+ end
164
+
165
+ ActiveRecord::Base.transaction do
166
+ migration_instance = migration_class.new
167
+ migration_instance.public_send(direction)
168
+
169
+ connection = ActiveRecord::Base.connection
170
+ version_str = connection.quote(migration.version.to_s)
171
+
172
+ if direction == :up
173
+ connection.execute(
174
+ "INSERT INTO #{MIGRATIONS_TABLE_NAME} (version) VALUES (#{version_str})"
175
+ )
176
+ else
177
+ connection.execute(
178
+ "DELETE FROM #{MIGRATIONS_TABLE_NAME} WHERE version = #{version_str}"
179
+ )
180
+ # Clean up registry entries for triggers that no longer exist in database
181
+ cleanup_orphaned_registry_entries
182
+ end
183
+ end
184
+ rescue LoadError => e
185
+ raise StandardError, "Error loading trigger migration #{migration.filename}: #{e.message}"
186
+ rescue StandardError => e
187
+ raise StandardError,
188
+ "Error running trigger migration #{migration.filename}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
189
+ end
190
+
191
+ def status
192
+ ensure_migrations_table!
193
+ current_version
194
+
195
+ migrations.map do |migration|
196
+ # Check if this specific migration version exists in the migrations table
197
+ # This is more reliable than just comparing versions
198
+ quoted_version = ActiveRecord::Base.connection.quote(migration.version.to_s)
199
+ version_exists = ActiveRecord::Base.connection.select_value(
200
+ "SELECT 1 FROM #{MIGRATIONS_TABLE_NAME} WHERE version = #{quoted_version} LIMIT 1"
201
+ )
202
+ ran = version_exists.present?
203
+
204
+ {
205
+ version: migration.version,
206
+ name: migration.name,
207
+ status: ran ? "up" : "down",
208
+ filename: migration.filename
209
+ }
210
+ end
211
+ end
212
+
213
+ def version
214
+ current_version
215
+ end
216
+
217
+ # Clean up registry entries for triggers that no longer exist in the database
218
+ # This is called after rolling back migrations to keep the registry in sync
219
+ def cleanup_orphaned_registry_entries
220
+ return unless ActiveRecord::Base.connection.table_exists?("pg_sql_triggers_registry")
221
+
222
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
223
+
224
+ # Get all triggers from registry
225
+ registry_triggers = PgSqlTriggers::TriggerRegistry.all
226
+
227
+ # Remove registry entries for triggers that don't exist in database
228
+ registry_triggers.each do |registry_trigger|
229
+ unless introspection.trigger_exists?(registry_trigger.trigger_name)
230
+ Rails.logger.info("Removing orphaned registry entry for trigger: #{registry_trigger.trigger_name}")
231
+ registry_trigger.destroy
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Permissions
5
+ class Checker
6
+ def self.can?(actor, action, environment: nil)
7
+ action_sym = action.to_sym
8
+
9
+ # If custom permission checker is configured, use it
10
+ if PgSqlTriggers.permission_checker
11
+ environment ||= PgSqlTriggers.default_environment.call if PgSqlTriggers.default_environment.respond_to?(:call)
12
+ return PgSqlTriggers.permission_checker.call(actor, action_sym, environment)
13
+ end
14
+
15
+ # Default behavior: allow all permissions
16
+ # This should be overridden in production via configuration
17
+ true
18
+ end
19
+
20
+ # rubocop:disable Naming/PredicateMethod
21
+ def self.check!(actor, action, environment: nil)
22
+ unless can?(actor, action, environment: environment)
23
+ action_sym = action.to_sym
24
+ required_level = Permissions::ACTIONS[action_sym] || "unknown"
25
+ raise PgSqlTriggers::PermissionError,
26
+ "Permission denied: #{action_sym} requires #{required_level} level access"
27
+ end
28
+ true
29
+ end
30
+ # rubocop:enable Naming/PredicateMethod
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Permissions
5
+ autoload :Checker, "pg_sql_triggers/permissions/checker"
6
+
7
+ # Permission levels
8
+ VIEWER = "viewer"
9
+ OPERATOR = "operator"
10
+ ADMIN = "admin"
11
+
12
+ # Actions
13
+ ACTIONS = {
14
+ view_triggers: VIEWER,
15
+ view_diffs: VIEWER,
16
+ enable_trigger: OPERATOR,
17
+ disable_trigger: OPERATOR,
18
+ apply_trigger: OPERATOR,
19
+ dry_run_sql: OPERATOR,
20
+ generate_trigger: OPERATOR,
21
+ test_trigger: OPERATOR,
22
+ drop_trigger: ADMIN,
23
+ execute_sql: ADMIN,
24
+ override_drift: ADMIN
25
+ }.freeze
26
+
27
+ def self.check!(actor, action, environment: nil)
28
+ Checker.check!(actor, action, environment: environment)
29
+ end
30
+
31
+ def self.can?(actor, action, environment: nil)
32
+ Checker.can?(actor, action, environment: environment)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Registry
5
+ class Manager
6
+ class << self
7
+ def register(definition)
8
+ trigger_name = definition.name
9
+ existing = TriggerRegistry.find_by(trigger_name: trigger_name)
10
+
11
+ attributes = {
12
+ trigger_name: definition.name,
13
+ table_name: definition.table_name,
14
+ version: definition.version,
15
+ enabled: definition.enabled,
16
+ source: "dsl",
17
+ environment: definition.environments.join(","),
18
+ definition: definition.to_h.to_json
19
+ }
20
+
21
+ if existing
22
+ existing.update!(attributes)
23
+ existing
24
+ else
25
+ TriggerRegistry.create!(attributes.merge(checksum: "placeholder"))
26
+ end
27
+ end
28
+
29
+ def list
30
+ TriggerRegistry.all
31
+ end
32
+
33
+ delegate :enabled, to: :TriggerRegistry
34
+
35
+ delegate :disabled, to: :TriggerRegistry
36
+
37
+ delegate :for_table, to: :TriggerRegistry
38
+
39
+ def diff
40
+ # Compare DSL definitions with actual database state
41
+ # This will be implemented in the Drift::Detector
42
+ Drift.detect
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Registry
5
+ class Validator
6
+ # rubocop:disable Naming/PredicateMethod
7
+ def self.validate!
8
+ # Validates all registry entries
9
+ # This is a placeholder implementation
10
+ true
11
+ end
12
+ # rubocop:enable Naming/PredicateMethod
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module Registry
5
+ autoload :Manager, "pg_sql_triggers/registry/manager"
6
+ autoload :Validator, "pg_sql_triggers/registry/validator"
7
+
8
+ def self.register(definition)
9
+ Manager.register(definition)
10
+ end
11
+
12
+ def self.list
13
+ Manager.list
14
+ end
15
+
16
+ def self.enabled
17
+ Manager.enabled
18
+ end
19
+
20
+ def self.disabled
21
+ Manager.disabled
22
+ end
23
+
24
+ def self.for_table(table_name)
25
+ Manager.for_table(table_name)
26
+ end
27
+
28
+ def self.diff
29
+ Manager.diff
30
+ end
31
+
32
+ def self.validate!
33
+ Validator.validate!
34
+ end
35
+ end
36
+ end