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,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,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
|