pg_sql_triggers 1.3.0 → 1.4.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 +4 -4
- data/CHANGELOG.md +253 -1
- data/GEM_ANALYSIS.md +368 -0
- data/README.md +20 -23
- data/app/models/pg_sql_triggers/trigger_registry.rb +42 -6
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +1 -4
- data/app/views/pg_sql_triggers/tables/index.html.erb +1 -4
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
- data/config/routes.rb +0 -14
- data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/api-reference.md +44 -153
- data/docs/configuration.md +24 -3
- data/docs/getting-started.md +17 -16
- data/docs/usage-guide.md +38 -67
- data/docs/web-ui.md +3 -103
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
- data/lib/pg_sql_triggers/drift/detector.rb +51 -38
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
- data/lib/pg_sql_triggers/engine.rb +14 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
- data/lib/pg_sql_triggers/migrator.rb +53 -6
- data/lib/pg_sql_triggers/registry/manager.rb +36 -11
- data/lib/pg_sql_triggers/registry/validator.rb +62 -5
- data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -275
- data/lib/pg_sql_triggers/sql.rb +0 -6
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +4 -1
- data/pg_sql_triggers.gemspec +53 -0
- metadata +7 -13
- data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
- data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
- data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
- data/lib/generators/trigger/migration_generator.rb +0 -60
- data/lib/pg_sql_triggers/generator/form.rb +0 -80
- data/lib/pg_sql_triggers/generator/service.rb +0 -339
- data/lib/pg_sql_triggers/generator.rb +0 -8
- data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
- data/lib/pg_sql_triggers/sql/executor.rb +0 -200
|
@@ -1,339 +0,0 @@
|
|
|
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
|
-
# Service object for generating trigger DSL files, migration files, and registering triggers.
|
|
10
|
-
#
|
|
11
|
-
# This service follows the service object pattern with class methods for stateless operations.
|
|
12
|
-
#
|
|
13
|
-
# @example Generate trigger files
|
|
14
|
-
# form = PgSqlTriggers::Generator::Form.new(trigger_name: "users_email_validation", ...)
|
|
15
|
-
# result = PgSqlTriggers::Generator::Service.create_trigger(form, actor: current_user)
|
|
16
|
-
#
|
|
17
|
-
# if result[:success]
|
|
18
|
-
# puts "Created: #{result[:migration_path]}"
|
|
19
|
-
# end
|
|
20
|
-
# rubocop:disable Metrics/ClassLength
|
|
21
|
-
class Service
|
|
22
|
-
class << self
|
|
23
|
-
# Generates the DSL trigger definition code from a form.
|
|
24
|
-
#
|
|
25
|
-
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
26
|
-
# @return [String] The generated DSL code
|
|
27
|
-
def generate_dsl(form)
|
|
28
|
-
# Generate DSL trigger definition
|
|
29
|
-
events_list = form.events.compact_blank.map { |e| ":#{e}" }.join(", ")
|
|
30
|
-
environments_list = form.environments.compact_blank.map { |e| ":#{e}" }.join(", ")
|
|
31
|
-
|
|
32
|
-
# Format function name (can be string or symbol)
|
|
33
|
-
function_ref = if form.function_name.to_s.match?(/\A[a-z0-9_]+\z/)
|
|
34
|
-
":#{form.function_name}"
|
|
35
|
-
else
|
|
36
|
-
"\"#{form.function_name}\""
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
code = <<~RUBY
|
|
40
|
-
# frozen_string_literal: true
|
|
41
|
-
|
|
42
|
-
# Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
|
|
43
|
-
PgSqlTriggers::DSL.pg_sql_trigger "#{form.trigger_name}" do
|
|
44
|
-
table :#{form.table_name}
|
|
45
|
-
on #{events_list}
|
|
46
|
-
function #{function_ref}
|
|
47
|
-
#{' '}
|
|
48
|
-
version #{form.version}
|
|
49
|
-
enabled #{form.enabled}
|
|
50
|
-
timing :#{form.timing || 'before'}
|
|
51
|
-
RUBY
|
|
52
|
-
|
|
53
|
-
code += " when_env #{environments_list}\n" if form.environments.compact_blank.any?
|
|
54
|
-
|
|
55
|
-
code += " when_condition \"#{form.condition.gsub('"', '\\"')}\"\n" if form.condition.present?
|
|
56
|
-
|
|
57
|
-
code += "end\n"
|
|
58
|
-
|
|
59
|
-
code
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Generates the migration file code from a form.
|
|
63
|
-
#
|
|
64
|
-
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
65
|
-
# @return [String] The generated migration code
|
|
66
|
-
def generate_migration(form)
|
|
67
|
-
# Generate migration class code
|
|
68
|
-
# Use Rails migration naming convention: Add{TriggerName}
|
|
69
|
-
# This avoids Zeitwerk autoload conflicts with app/triggers files
|
|
70
|
-
class_name = "Add#{form.trigger_name.camelize}"
|
|
71
|
-
events_sql = form.events.compact_blank.map(&:upcase).join(" OR ")
|
|
72
|
-
function_body_sql = form.function_body.strip
|
|
73
|
-
|
|
74
|
-
# Build the trigger creation SQL
|
|
75
|
-
timing_value = (form.timing || "before").upcase
|
|
76
|
-
trigger_sql = "CREATE TRIGGER #{form.trigger_name}\n"
|
|
77
|
-
trigger_sql += "#{timing_value} #{events_sql} ON #{form.table_name}\n"
|
|
78
|
-
trigger_sql += "FOR EACH ROW\n"
|
|
79
|
-
trigger_sql += "WHEN (#{form.condition})\n" if form.condition.present?
|
|
80
|
-
trigger_sql += "EXECUTE FUNCTION #{form.function_name}();"
|
|
81
|
-
|
|
82
|
-
# Build the down method SQL
|
|
83
|
-
down_sql = "DROP TRIGGER IF EXISTS #{form.trigger_name} ON #{form.table_name};\n"
|
|
84
|
-
down_sql += "DROP FUNCTION IF EXISTS #{form.function_name}();"
|
|
85
|
-
|
|
86
|
-
# Indent SQL strings to match heredoc indentation (18 spaces)
|
|
87
|
-
indented_function_body = indent_sql(function_body_sql, 18)
|
|
88
|
-
indented_trigger_sql = indent_sql(trigger_sql, 18)
|
|
89
|
-
indented_down_sql = indent_sql(down_sql, 18)
|
|
90
|
-
|
|
91
|
-
<<~RUBY
|
|
92
|
-
# frozen_string_literal: true
|
|
93
|
-
|
|
94
|
-
# Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
|
|
95
|
-
class #{class_name} < PgSqlTriggers::Migration
|
|
96
|
-
def up
|
|
97
|
-
# Create the function
|
|
98
|
-
execute <<-SQL
|
|
99
|
-
#{indented_function_body}
|
|
100
|
-
SQL
|
|
101
|
-
|
|
102
|
-
# Create the trigger
|
|
103
|
-
execute <<-SQL
|
|
104
|
-
#{indented_trigger_sql}
|
|
105
|
-
SQL
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def down
|
|
109
|
-
execute <<-SQL
|
|
110
|
-
#{indented_down_sql}
|
|
111
|
-
SQL
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
RUBY
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Generates a PL/pgSQL function stub template.
|
|
118
|
-
#
|
|
119
|
-
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
120
|
-
# @return [String, nil] The generated function stub SQL, or nil if not requested
|
|
121
|
-
def generate_function_stub(form)
|
|
122
|
-
return nil unless form.generate_function_stub
|
|
123
|
-
|
|
124
|
-
# Generate PL/pgSQL function template
|
|
125
|
-
events_display = form.events.compact_blank.join(", ").upcase
|
|
126
|
-
|
|
127
|
-
<<~SQL
|
|
128
|
-
-- PL/pgSQL function for trigger: #{form.trigger_name}
|
|
129
|
-
-- Table: #{form.table_name}
|
|
130
|
-
-- Events: #{events_display}
|
|
131
|
-
-- Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
|
|
132
|
-
--
|
|
133
|
-
-- TODO: Implement your trigger logic below
|
|
134
|
-
|
|
135
|
-
CREATE OR REPLACE FUNCTION #{form.function_name}()
|
|
136
|
-
RETURNS TRIGGER AS $$
|
|
137
|
-
BEGIN
|
|
138
|
-
-- Access OLD record: OLD.column_name (for UPDATE, DELETE)
|
|
139
|
-
-- Access NEW record: NEW.column_name (for INSERT, UPDATE)
|
|
140
|
-
|
|
141
|
-
-- Example: Validate a field
|
|
142
|
-
-- IF NEW.some_field > threshold THEN
|
|
143
|
-
-- RAISE EXCEPTION 'Validation failed: %', NEW.some_field;
|
|
144
|
-
-- END IF;
|
|
145
|
-
|
|
146
|
-
-- TODO: Add your validation/business logic here
|
|
147
|
-
RAISE NOTICE 'Trigger function % called for %', TG_NAME, TG_OP;
|
|
148
|
-
|
|
149
|
-
-- For INSERT/UPDATE triggers, return NEW
|
|
150
|
-
-- For DELETE triggers, return OLD
|
|
151
|
-
-- For TRUNCATE triggers, return NULL
|
|
152
|
-
IF TG_OP = 'DELETE' THEN
|
|
153
|
-
RETURN OLD;
|
|
154
|
-
ELSIF TG_OP = 'TRUNCATE' THEN
|
|
155
|
-
RETURN NULL;
|
|
156
|
-
ELSE
|
|
157
|
-
RETURN NEW;
|
|
158
|
-
END IF;
|
|
159
|
-
END;
|
|
160
|
-
$$ LANGUAGE plpgsql;
|
|
161
|
-
SQL
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# Returns the file paths where the migration and DSL files will be created.
|
|
165
|
-
#
|
|
166
|
-
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
167
|
-
# @return [Hash] Hash with :migration and :dsl keys containing relative file paths
|
|
168
|
-
def file_paths(form)
|
|
169
|
-
# NOTE: These paths are relative to the host Rails app, not the gem
|
|
170
|
-
# Generate both migration file and DSL file
|
|
171
|
-
migration_version = next_migration_number
|
|
172
|
-
{
|
|
173
|
-
migration: "db/triggers/#{migration_version}_#{form.trigger_name}.rb",
|
|
174
|
-
dsl: "app/triggers/#{form.trigger_name}.rb"
|
|
175
|
-
}
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Creates trigger files (DSL and migration) and registers the trigger in the registry.
|
|
179
|
-
#
|
|
180
|
-
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
181
|
-
# @param actor [Hash, nil] Optional actor information for audit logging
|
|
182
|
-
# @return [Hash] Result hash with :success (Boolean), :migration_path, :dsl_path, and optional :error
|
|
183
|
-
def create_trigger(form, actor: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
184
|
-
paths = file_paths(form)
|
|
185
|
-
base_path = rails_base_path
|
|
186
|
-
|
|
187
|
-
create_trigger_files(form, paths, base_path)
|
|
188
|
-
registry = register_trigger(form)
|
|
189
|
-
|
|
190
|
-
build_success_response(registry, paths, form)
|
|
191
|
-
rescue StandardError => e
|
|
192
|
-
log_error(e)
|
|
193
|
-
build_error_response(e)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
private
|
|
197
|
-
|
|
198
|
-
def indent_sql(sql_string, indent_level)
|
|
199
|
-
indent = " " * indent_level
|
|
200
|
-
sql_string.lines.map do |line|
|
|
201
|
-
stripped = line.chomp
|
|
202
|
-
stripped.empty? ? "" : indent + stripped
|
|
203
|
-
end.join("\n")
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def rails_base_path
|
|
207
|
-
defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def create_trigger_files(form, paths, base_path)
|
|
211
|
-
full_migration_path = base_path.join(paths[:migration])
|
|
212
|
-
full_dsl_path = base_path.join(paths[:dsl])
|
|
213
|
-
|
|
214
|
-
FileUtils.mkdir_p(full_migration_path.dirname)
|
|
215
|
-
FileUtils.mkdir_p(full_dsl_path.dirname)
|
|
216
|
-
|
|
217
|
-
migration_content = generate_migration(form)
|
|
218
|
-
dsl_content = generate_dsl(form)
|
|
219
|
-
|
|
220
|
-
File.write(full_migration_path, migration_content)
|
|
221
|
-
File.write(full_dsl_path, dsl_content)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def register_trigger(form)
|
|
225
|
-
definition = build_trigger_definition(form)
|
|
226
|
-
attributes = build_registry_attributes(form, definition)
|
|
227
|
-
TriggerRegistry.create!(attributes)
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def build_trigger_definition(form)
|
|
231
|
-
{
|
|
232
|
-
name: form.trigger_name,
|
|
233
|
-
table_name: form.table_name,
|
|
234
|
-
events: form.events.compact_blank,
|
|
235
|
-
function_name: form.function_name,
|
|
236
|
-
version: form.version,
|
|
237
|
-
enabled: form.enabled,
|
|
238
|
-
environments: form.environments.compact_blank,
|
|
239
|
-
condition: form.condition,
|
|
240
|
-
timing: form.timing || "before",
|
|
241
|
-
function_body: form.function_body
|
|
242
|
-
}
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def build_registry_attributes(form, definition)
|
|
246
|
-
attributes = {
|
|
247
|
-
trigger_name: form.trigger_name,
|
|
248
|
-
table_name: form.table_name,
|
|
249
|
-
version: form.version,
|
|
250
|
-
enabled: form.enabled,
|
|
251
|
-
source: "dsl",
|
|
252
|
-
environment: form.environments.compact_blank.join(",").presence,
|
|
253
|
-
definition: definition.to_json,
|
|
254
|
-
function_body: form.function_body,
|
|
255
|
-
checksum: calculate_checksum(definition)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
add_conditional_attributes(attributes, form)
|
|
259
|
-
attributes
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def add_conditional_attributes(attributes, form)
|
|
263
|
-
column_names = TriggerRegistry.column_names
|
|
264
|
-
|
|
265
|
-
attributes[:condition] = form.condition.presence if column_names.include?("condition")
|
|
266
|
-
attributes[:timing] = (form.timing || "before") if column_names.include?("timing")
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
def build_success_response(registry, paths, form)
|
|
270
|
-
{
|
|
271
|
-
success: true,
|
|
272
|
-
registry_id: registry.id,
|
|
273
|
-
migration_path: paths[:migration],
|
|
274
|
-
dsl_path: paths[:dsl],
|
|
275
|
-
metadata: {
|
|
276
|
-
trigger_name: form.trigger_name,
|
|
277
|
-
table_name: form.table_name,
|
|
278
|
-
events: form.events.compact_blank,
|
|
279
|
-
files_created: [paths[:migration], paths[:dsl]]
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
def log_error(error)
|
|
285
|
-
return unless defined?(Rails)
|
|
286
|
-
|
|
287
|
-
Rails.logger.error("Trigger generation failed: #{error.message}")
|
|
288
|
-
Rails.logger.error(error.backtrace.join("\n"))
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def build_error_response(error)
|
|
292
|
-
{
|
|
293
|
-
success: false,
|
|
294
|
-
error: error.message
|
|
295
|
-
}
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def next_migration_number
|
|
299
|
-
# Determine if we're in a Rails app context or standalone gem
|
|
300
|
-
base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
|
|
301
|
-
triggers_path = base_path.join("db", "triggers")
|
|
302
|
-
|
|
303
|
-
# Get the highest migration number from existing migrations
|
|
304
|
-
existing = if Dir.exist?(triggers_path)
|
|
305
|
-
Dir.glob(triggers_path.join("*.rb"))
|
|
306
|
-
.map { |f| File.basename(f, ".rb").split("_").first.to_i }
|
|
307
|
-
.reject(&:zero?)
|
|
308
|
-
.max || 0
|
|
309
|
-
else
|
|
310
|
-
0
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
# Generate next timestamp-based version
|
|
314
|
-
# Format: YYYYMMDDHHMMSS
|
|
315
|
-
now = Time.now.utc
|
|
316
|
-
base = now.strftime("%Y%m%d%H%M%S").to_i
|
|
317
|
-
|
|
318
|
-
# If we have existing migrations, ensure we're incrementing
|
|
319
|
-
base = existing + 1 if existing.positive? && base <= existing
|
|
320
|
-
|
|
321
|
-
base
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def calculate_checksum(definition)
|
|
325
|
-
# Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
|
|
326
|
-
Digest::SHA256.hexdigest([
|
|
327
|
-
definition[:name],
|
|
328
|
-
definition[:table_name],
|
|
329
|
-
definition[:version],
|
|
330
|
-
definition[:function_body] || "",
|
|
331
|
-
definition[:condition] || "",
|
|
332
|
-
definition[:timing] || "before"
|
|
333
|
-
].join)
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
# rubocop:enable Metrics/ClassLength
|
|
338
|
-
end
|
|
339
|
-
end
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
|
|
5
|
-
module PgSqlTriggers
|
|
6
|
-
module SQL
|
|
7
|
-
# Capsule represents a named SQL capsule with environment declaration and purpose
|
|
8
|
-
# Used for emergency operations and manual SQL execution
|
|
9
|
-
#
|
|
10
|
-
# @example Creating a SQL capsule
|
|
11
|
-
# capsule = PgSqlTriggers::SQL::Capsule.new(
|
|
12
|
-
# name: "fix_user_permissions",
|
|
13
|
-
# environment: "production",
|
|
14
|
-
# purpose: "Emergency fix for user permission issue",
|
|
15
|
-
# sql: "UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';"
|
|
16
|
-
# )
|
|
17
|
-
#
|
|
18
|
-
class Capsule
|
|
19
|
-
attr_reader :name, :environment, :purpose, :sql, :created_at
|
|
20
|
-
|
|
21
|
-
# @param name [String] The name of the SQL capsule
|
|
22
|
-
# @param environment [String] The environment this capsule is intended for
|
|
23
|
-
# @param purpose [String] Description of what this capsule does and why
|
|
24
|
-
# @param sql [String] The SQL to execute
|
|
25
|
-
# @param created_at [Time, nil] The timestamp when the capsule was created (defaults to now)
|
|
26
|
-
def initialize(name:, environment:, purpose:, sql:, created_at: nil)
|
|
27
|
-
@name = name
|
|
28
|
-
@environment = environment
|
|
29
|
-
@purpose = purpose
|
|
30
|
-
@sql = sql
|
|
31
|
-
@created_at = created_at || Time.current
|
|
32
|
-
validate!
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Calculates the checksum of the SQL content
|
|
36
|
-
# @return [String] The SHA256 checksum of the SQL
|
|
37
|
-
def checksum
|
|
38
|
-
@checksum ||= Digest::SHA256.hexdigest(sql.to_s)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Converts the capsule to a hash suitable for storage
|
|
42
|
-
# @return [Hash] The capsule data
|
|
43
|
-
def to_h
|
|
44
|
-
{
|
|
45
|
-
name: name,
|
|
46
|
-
environment: environment,
|
|
47
|
-
purpose: purpose,
|
|
48
|
-
sql: sql,
|
|
49
|
-
checksum: checksum,
|
|
50
|
-
created_at: created_at
|
|
51
|
-
}
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Returns the registry trigger name for this capsule
|
|
55
|
-
# SQL capsules are stored in the registry with a special naming pattern
|
|
56
|
-
# @return [String] The trigger name for registry storage
|
|
57
|
-
def registry_trigger_name
|
|
58
|
-
"sql_capsule_#{name}"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def validate!
|
|
64
|
-
errors = []
|
|
65
|
-
errors << "Name cannot be blank" if name.nil? || name.to_s.strip.empty?
|
|
66
|
-
errors << "Environment cannot be blank" if environment.nil? || environment.to_s.strip.empty?
|
|
67
|
-
errors << "Purpose cannot be blank" if purpose.nil? || purpose.to_s.strip.empty?
|
|
68
|
-
errors << "SQL cannot be blank" if sql.nil? || sql.to_s.strip.empty?
|
|
69
|
-
|
|
70
|
-
# Validate name format (alphanumeric, underscores, hyphens only)
|
|
71
|
-
unless name.to_s.match?(/\A[a-z0-9_-]+\z/i)
|
|
72
|
-
errors << "Name must contain only letters, numbers, underscores, and hyphens"
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
raise ArgumentError, "Invalid capsule: #{errors.join(', ')}" if errors.any?
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module PgSqlTriggers
|
|
4
|
-
module SQL
|
|
5
|
-
# Executor handles the execution of SQL capsules with safety checks and logging
|
|
6
|
-
#
|
|
7
|
-
# @example Execute a SQL capsule
|
|
8
|
-
# capsule = PgSqlTriggers::SQL::Capsule.new(name: "fix", ...)
|
|
9
|
-
# result = PgSqlTriggers::SQL::Executor.execute(capsule, actor: current_actor, confirmation: "EXECUTE FIX")
|
|
10
|
-
#
|
|
11
|
-
class Executor
|
|
12
|
-
class << self
|
|
13
|
-
# Executes a SQL capsule with safety checks
|
|
14
|
-
#
|
|
15
|
-
# @param capsule [Capsule] The SQL capsule to execute
|
|
16
|
-
# @param actor [Hash] Information about who is executing the capsule
|
|
17
|
-
# @param confirmation [String, nil] The confirmation text for kill switch
|
|
18
|
-
# @param dry_run [Boolean] If true, only validate without executing
|
|
19
|
-
# @return [Hash] Result of the execution with :success, :message, and :data keys
|
|
20
|
-
def execute(capsule, actor:, confirmation: nil, dry_run: false)
|
|
21
|
-
validate_capsule!(capsule)
|
|
22
|
-
|
|
23
|
-
# Check permissions
|
|
24
|
-
check_permissions!(actor)
|
|
25
|
-
|
|
26
|
-
# Check kill switch
|
|
27
|
-
check_kill_switch!(capsule, actor, confirmation)
|
|
28
|
-
|
|
29
|
-
# Log the execution attempt
|
|
30
|
-
log_execution_attempt(capsule, actor, dry_run)
|
|
31
|
-
|
|
32
|
-
if dry_run
|
|
33
|
-
return {
|
|
34
|
-
success: true,
|
|
35
|
-
message: "Dry run successful. SQL would be executed.",
|
|
36
|
-
data: { checksum: capsule.checksum }
|
|
37
|
-
}
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Execute in transaction
|
|
41
|
-
result = execute_in_transaction(capsule, actor)
|
|
42
|
-
|
|
43
|
-
# Update registry after successful execution
|
|
44
|
-
update_registry(capsule) if result[:success]
|
|
45
|
-
|
|
46
|
-
# Log the result
|
|
47
|
-
log_execution_result(capsule, actor, result)
|
|
48
|
-
|
|
49
|
-
result
|
|
50
|
-
rescue StandardError => e
|
|
51
|
-
log_execution_error(capsule, actor, e)
|
|
52
|
-
{
|
|
53
|
-
success: false,
|
|
54
|
-
message: "Execution failed: #{e.message}",
|
|
55
|
-
error: e
|
|
56
|
-
}
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Executes a SQL capsule by name from the registry
|
|
60
|
-
#
|
|
61
|
-
# @param capsule_name [String] The name of the capsule to execute
|
|
62
|
-
# @param actor [Hash] Information about who is executing the capsule
|
|
63
|
-
# @param confirmation [String, nil] The confirmation text for kill switch
|
|
64
|
-
# @param dry_run [Boolean] If true, only validate without executing
|
|
65
|
-
# @return [Hash] Result of the execution
|
|
66
|
-
def execute_capsule(capsule_name, actor:, confirmation: nil, dry_run: false)
|
|
67
|
-
capsule = load_capsule_from_registry(capsule_name)
|
|
68
|
-
|
|
69
|
-
unless capsule
|
|
70
|
-
return {
|
|
71
|
-
success: false,
|
|
72
|
-
message: "Capsule '#{capsule_name}' not found in registry"
|
|
73
|
-
}
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
execute(capsule, actor: actor, confirmation: confirmation, dry_run: dry_run)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
def validate_capsule!(capsule)
|
|
82
|
-
raise ArgumentError, "Capsule must be a PgSqlTriggers::SQL::Capsule" unless capsule.is_a?(Capsule)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def check_permissions!(actor)
|
|
86
|
-
PgSqlTriggers::Permissions.check!(actor, :execute_sql)
|
|
87
|
-
rescue PgSqlTriggers::PermissionError => e
|
|
88
|
-
raise PgSqlTriggers::PermissionError,
|
|
89
|
-
"SQL capsule execution requires Admin role: #{e.message}"
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def check_kill_switch!(_capsule, actor, confirmation)
|
|
93
|
-
PgSqlTriggers::SQL::KillSwitch.check!(
|
|
94
|
-
operation: :execute_sql_capsule,
|
|
95
|
-
environment: Rails.env,
|
|
96
|
-
confirmation: confirmation,
|
|
97
|
-
actor: actor
|
|
98
|
-
)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def execute_in_transaction(capsule, _actor)
|
|
102
|
-
ActiveRecord::Base.transaction do
|
|
103
|
-
result = ActiveRecord::Base.connection.execute(capsule.sql)
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
success: true,
|
|
107
|
-
message: "SQL capsule '#{capsule.name}' executed successfully",
|
|
108
|
-
data: {
|
|
109
|
-
checksum: capsule.checksum,
|
|
110
|
-
rows_affected: result.cmd_tuples || 0
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def update_registry(capsule)
|
|
117
|
-
# Check if capsule already exists in registry
|
|
118
|
-
registry_entry = PgSqlTriggers::TriggerRegistry.find_or_initialize_by(
|
|
119
|
-
trigger_name: capsule.registry_trigger_name
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
registry_entry.assign_attributes(
|
|
123
|
-
table_name: "manual_sql_execution",
|
|
124
|
-
version: Time.current.to_i,
|
|
125
|
-
checksum: capsule.checksum,
|
|
126
|
-
source: "manual_sql",
|
|
127
|
-
function_body: capsule.sql,
|
|
128
|
-
condition: capsule.purpose,
|
|
129
|
-
environment: capsule.environment,
|
|
130
|
-
enabled: true,
|
|
131
|
-
last_executed_at: Time.current
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
registry_entry.save!
|
|
135
|
-
rescue StandardError => e
|
|
136
|
-
logger&.error "[SQL_CAPSULE] Failed to update registry: #{e.message}"
|
|
137
|
-
# Don't fail the execution if registry update fails
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def load_capsule_from_registry(capsule_name)
|
|
141
|
-
trigger_name = "sql_capsule_#{capsule_name}"
|
|
142
|
-
registry_entry = PgSqlTriggers::TriggerRegistry.find_by(
|
|
143
|
-
trigger_name: trigger_name,
|
|
144
|
-
source: "manual_sql"
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
return nil unless registry_entry
|
|
148
|
-
|
|
149
|
-
Capsule.new(
|
|
150
|
-
name: capsule_name,
|
|
151
|
-
environment: registry_entry.environment || Rails.env.to_s,
|
|
152
|
-
purpose: registry_entry.condition || "No purpose specified",
|
|
153
|
-
sql: registry_entry.function_body,
|
|
154
|
-
created_at: registry_entry.created_at
|
|
155
|
-
)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Logging methods
|
|
159
|
-
|
|
160
|
-
def log_execution_attempt(capsule, actor, dry_run)
|
|
161
|
-
mode = dry_run ? "DRY_RUN" : "EXECUTE"
|
|
162
|
-
logger&.info "[SQL_CAPSULE] #{mode} ATTEMPT: name=#{capsule.name} " \
|
|
163
|
-
"environment=#{capsule.environment} actor=#{format_actor(actor)}"
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def log_execution_result(capsule, actor, result)
|
|
167
|
-
status = result[:success] ? "SUCCESS" : "FAILED"
|
|
168
|
-
logger&.info "[SQL_CAPSULE] #{status}: name=#{capsule.name} " \
|
|
169
|
-
"environment=#{capsule.environment} actor=#{format_actor(actor)} " \
|
|
170
|
-
"checksum=#{capsule.checksum}"
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def log_execution_error(capsule, actor, error)
|
|
174
|
-
# Handle case where capsule might not be valid
|
|
175
|
-
capsule_name = capsule.respond_to?(:name) ? capsule.name : "invalid_capsule"
|
|
176
|
-
environment = capsule.respond_to?(:environment) ? capsule.environment : "unknown"
|
|
177
|
-
|
|
178
|
-
logger&.error "[SQL_CAPSULE] ERROR: name=#{capsule_name} " \
|
|
179
|
-
"environment=#{environment} actor=#{format_actor(actor)} " \
|
|
180
|
-
"error=#{error.class.name} message=#{error.message}"
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def format_actor(actor)
|
|
184
|
-
return "unknown" if actor.nil?
|
|
185
|
-
return actor.to_s unless actor.is_a?(Hash)
|
|
186
|
-
|
|
187
|
-
"#{actor[:type] || 'unknown'}:#{actor[:id] || 'unknown'}"
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def logger
|
|
191
|
-
if PgSqlTriggers.respond_to?(:logger) && PgSqlTriggers.logger
|
|
192
|
-
PgSqlTriggers.logger
|
|
193
|
-
elsif defined?(Rails) && Rails.respond_to?(:logger)
|
|
194
|
-
Rails.logger
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|