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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +253 -1
  3. data/GEM_ANALYSIS.md +368 -0
  4. data/README.md +20 -23
  5. data/app/models/pg_sql_triggers/trigger_registry.rb +42 -6
  6. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
  7. data/app/views/pg_sql_triggers/dashboard/index.html.erb +1 -4
  8. data/app/views/pg_sql_triggers/tables/index.html.erb +1 -4
  9. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
  10. data/config/routes.rb +0 -14
  11. data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  12. data/docs/api-reference.md +44 -153
  13. data/docs/configuration.md +24 -3
  14. data/docs/getting-started.md +17 -16
  15. data/docs/usage-guide.md +38 -67
  16. data/docs/web-ui.md +3 -103
  17. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  18. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  19. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  20. data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
  21. data/lib/pg_sql_triggers/drift/detector.rb +51 -38
  22. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
  23. data/lib/pg_sql_triggers/engine.rb +14 -0
  24. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
  25. data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
  26. data/lib/pg_sql_triggers/migrator.rb +53 -6
  27. data/lib/pg_sql_triggers/registry/manager.rb +36 -11
  28. data/lib/pg_sql_triggers/registry/validator.rb +62 -5
  29. data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -275
  30. data/lib/pg_sql_triggers/sql.rb +0 -6
  31. data/lib/pg_sql_triggers/version.rb +1 -1
  32. data/lib/pg_sql_triggers.rb +4 -1
  33. data/pg_sql_triggers.gemspec +53 -0
  34. metadata +7 -13
  35. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  36. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  37. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  38. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  39. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  40. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  41. data/lib/generators/trigger/migration_generator.rb +0 -60
  42. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  43. data/lib/pg_sql_triggers/generator/service.rb +0 -339
  44. data/lib/pg_sql_triggers/generator.rb +0 -8
  45. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  46. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
data/README.md CHANGED
@@ -50,16 +50,18 @@ PgSqlTriggers::DSL.pg_sql_trigger "users_email_validation" do
50
50
  table :users
51
51
  on :insert, :update
52
52
  function :validate_user_email
53
- version 1
54
- enabled false
55
- when_env :production
53
+ self.version = 1
54
+ self.enabled = true
55
+ timing :before
56
56
  end
57
57
  ```
58
58
 
59
- ### Create and Run Migration
59
+ ### Generate and Run Migration
60
60
 
61
61
  ```bash
62
- rails generate trigger:migration add_email_validation
62
+ # Generate a DSL stub + migration in one command
63
+ rails generate pg_sql_triggers:trigger users_email_validation users insert update --timing before --function validate_user_email
64
+
63
65
  rake trigger:migrate
64
66
  ```
65
67
 
@@ -83,19 +85,26 @@ Comprehensive documentation is available in the [docs](docs/) directory:
83
85
  ## Key Features
84
86
 
85
87
  ### Trigger DSL
86
- Define triggers using a Rails-native Ruby DSL with versioning and environment control.
88
+ Define triggers using a Rails-native Ruby DSL with versioning, row/statement-level granularity, and timing control.
89
+
90
+ ### CLI Generator
91
+ Scaffold a DSL stub and migration in one command:
92
+ ```bash
93
+ rails generate pg_sql_triggers:trigger TRIGGER_NAME TABLE_NAME [EVENTS...] [--timing before|after] [--function fn_name]
94
+ ```
95
+ Files land in `app/triggers/` and `db/triggers/` for code review like any other source change.
87
96
 
88
97
  ### Migration System
89
98
  Manage trigger functions and definitions with a migration system similar to Rails schema migrations.
90
99
 
91
100
  ### Drift Detection
92
- Automatically detect when database triggers drift from your DSL definitions.
101
+ Automatically detect when database triggers drift from your DSL definitions. N+1-free bulk detection across all triggers.
93
102
 
94
103
  ### Production Kill Switch
95
104
  Multi-layered safety mechanism preventing accidental destructive operations in production environments.
96
105
 
97
106
  ### Web Dashboard
98
- Visual interface for managing triggers, running migrations, and executing SQL capsules. Includes:
107
+ Visual interface for managing triggers and running migrations. Includes:
99
108
  - **Quick Actions**: Enable/disable, drop, and re-execute triggers from dashboard
100
109
  - **Last Applied Tracking**: See when triggers were last applied with human-readable timestamps
101
110
  - **Breadcrumb Navigation**: Easy navigation between dashboard, tables, and triggers
@@ -104,18 +113,15 @@ Visual interface for managing triggers, running migrations, and executing SQL ca
104
113
  ### Audit Logging
105
114
  Comprehensive audit trail for all trigger operations:
106
115
  - Track who performed each operation (actor tracking)
107
- - Before and after state capture
116
+ - Before and after state capture (including function body)
108
117
  - Success/failure logging with error messages
109
118
  - Reason tracking for drop and re-execute operations
110
119
 
111
- ### SQL Capsules
112
- Emergency SQL execution feature for critical operations with Admin permission requirements, kill switch protection, and comprehensive logging.
113
-
114
120
  ### Drop & Re-Execute Flow
115
121
  Operational controls for trigger lifecycle management with drop and re-execute capabilities, drift comparison, and required reason logging.
116
122
 
117
123
  ### Permissions
118
- Three-tier permission system (Viewer, Operator, Admin) with customizable authorization.
124
+ Three-tier permission system (Viewer, Operator, Admin) with customizable authorization. A startup warning is emitted in production when no `permission_checker` is configured.
119
125
 
120
126
  ## Console API
121
127
 
@@ -142,15 +148,6 @@ PgSqlTriggers::Registry.disable("users_email_validation", actor: current_user, c
142
148
  # Drop and re-execute triggers
143
149
  PgSqlTriggers::Registry.drop("old_trigger", actor: current_user, reason: "No longer needed", confirmation: "EXECUTE TRIGGER_DROP")
144
150
  PgSqlTriggers::Registry.re_execute("drifted_trigger", actor: current_user, reason: "Fix drift", confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
145
-
146
- # Execute SQL capsules
147
- capsule = PgSqlTriggers::SQL::Capsule.new(
148
- name: "emergency_fix",
149
- environment: "production",
150
- purpose: "Fix critical data issue",
151
- sql: "UPDATE users SET status = 'active' WHERE id = 123"
152
- )
153
- PgSqlTriggers::SQL::Executor.execute(capsule, actor: current_user, confirmation: "EXECUTE SQL")
154
151
  ```
155
152
 
156
153
  See the [API Reference](docs/api-reference.md) for complete documentation of all console APIs.
@@ -164,7 +161,7 @@ For working examples and complete demonstrations, check out the [example reposit
164
161
  - **Rails-native**: Works seamlessly with Rails conventions
165
162
  - **Explicit over magic**: No automatic execution
166
163
  - **Safe by default**: Requires explicit confirmation for destructive actions
167
- - **Power with guardrails**: Emergency SQL escape hatches with safety checks
164
+ - **Code review first**: Generator produces files into working tree; no server-side file writes
168
165
 
169
166
  ## Development
170
167
 
@@ -247,7 +247,6 @@ module PgSqlTriggers
247
247
  # Execute DROP TRIGGER in transaction
248
248
  ActiveRecord::Base.transaction do
249
249
  drop_trigger_from_database
250
- trigger_name
251
250
  destroy!
252
251
  log_drop_success
253
252
  log_audit_success(:trigger_drop, actor, reason: reason, confirmation_text: confirmation,
@@ -286,7 +285,6 @@ module PgSqlTriggers
286
285
 
287
286
  # Validate reason is provided
288
287
  raise ArgumentError, "Reason is required" if reason.nil? || reason.to_s.strip.empty?
289
- raise StandardError, "Cannot re-execute: missing function_body" if function_body.blank?
290
288
 
291
289
  log_re_execute_attempt(reason)
292
290
 
@@ -322,7 +320,8 @@ module PgSqlTriggers
322
320
  version,
323
321
  function_body || "",
324
322
  condition || "",
325
- timing || "before"
323
+ timing || "before",
324
+ for_each || "row"
326
325
  ].join)
327
326
  end
328
327
 
@@ -394,7 +393,10 @@ module PgSqlTriggers
394
393
  end
395
394
 
396
395
  def recreate_trigger
397
- ActiveRecord::Base.connection.execute(function_body)
396
+ sql = function_body.presence || build_trigger_sql_from_definition
397
+ raise StandardError, "Cannot re-execute: missing function_body" if sql.blank?
398
+
399
+ ActiveRecord::Base.connection.execute(sql)
398
400
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
399
401
  Rails.logger.info "[TRIGGER_RE_EXECUTE] Re-created trigger"
400
402
  end
@@ -405,8 +407,41 @@ module PgSqlTriggers
405
407
  raise
406
408
  end
407
409
 
410
+ # Build a CREATE TRIGGER SQL statement from the stored DSL definition JSON.
411
+ # Used by re_execute! when function_body is absent (the normal case for DSL triggers).
412
+ def build_trigger_sql_from_definition
413
+ return nil if definition.blank?
414
+
415
+ defn = JSON.parse(definition)
416
+ fn_name = defn["function_name"]
417
+ return nil if fn_name.blank?
418
+
419
+ t_name = table_name
420
+ timing_kw = (defn["timing"] || timing || "BEFORE").upcase
421
+ events = Array(defn["events"]).map { |e| e.to_s.upcase }.join(" OR ")
422
+ events = "INSERT" if events.blank?
423
+ cond = defn["condition"] || condition
424
+ for_each_kw = (defn["for_each"] || for_each || "row").upcase
425
+
426
+ sql = "CREATE TRIGGER #{quote_identifier(trigger_name)} "
427
+ sql += "#{timing_kw} #{events} ON #{quote_identifier(t_name)} "
428
+ sql += "FOR EACH #{for_each_kw} "
429
+ sql += "WHEN (#{cond}) " if cond.present?
430
+ sql += "EXECUTE FUNCTION #{fn_name}();"
431
+ sql
432
+ rescue JSON::ParserError
433
+ nil
434
+ end
435
+
408
436
  def update_registry_after_re_execute
409
- update!(enabled: true, last_executed_at: Time.current)
437
+ update!(last_executed_at: Time.current)
438
+ if !enabled && ActiveRecord::Base.connection.table_exists?(table_name)
439
+ quoted_table = quote_identifier(table_name)
440
+ quoted_trigger = quote_identifier(trigger_name)
441
+ ActiveRecord::Base.connection.execute(
442
+ "ALTER TABLE #{quoted_table} DISABLE TRIGGER #{quoted_trigger};"
443
+ )
444
+ end
410
445
  Rails.logger.info "[TRIGGER_RE_EXECUTE] Updated registry" if defined?(Rails.logger)
411
446
  end
412
447
 
@@ -419,7 +454,8 @@ module PgSqlTriggers
419
454
  table_name: table_name,
420
455
  source: source,
421
456
  environment: environment,
422
- installed_at: installed_at&.iso8601
457
+ installed_at: installed_at&.iso8601,
458
+ function_body: function_body
423
459
  }
424
460
  end
425
461
 
@@ -18,7 +18,6 @@
18
18
  <div>
19
19
  <%= link_to "Dashboard", root_path, style: "color: white; margin-right: 1rem;" %>
20
20
  <%= link_to "Tables", tables_path, style: "color: white; margin-right: 1rem;" %>
21
- <%= link_to "Generator", new_generator_path, style: "color: white; margin-right: 1rem;" %>
22
21
  <%= link_to "Audit Log", audit_logs_path, style: "color: white;" %>
23
22
  </div>
24
23
  </div>
@@ -2,9 +2,6 @@
2
2
  <h2 style="margin: 0;">Trigger Dashboard</h2>
3
3
  <div style="display: flex; gap: 1rem;">
4
4
  <%= link_to "View Tables", tables_path, class: "btn btn-primary", style: "font-size: 1rem; padding: 0.75rem 1.5rem; text-decoration: none;" %>
5
- <%= link_to "Generate New Trigger", new_generator_path,
6
- class: "btn btn-success",
7
- style: "font-size: 1rem; padding: 0.75rem 1.5rem; text-decoration: none;" %>
8
5
  </div>
9
6
  </div>
10
7
 
@@ -129,7 +126,7 @@
129
126
  <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1.5rem; border-radius: 4px;">
130
127
  <h3 style="margin-top: 0;">No triggers yet</h3>
131
128
  <p style="margin-bottom: 1rem;">Get started by generating your first trigger using the form-based wizard.</p>
132
- <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
129
+ <%= link_to "View Tables", tables_path, class: "btn btn-primary" %>
133
130
  </div>
134
131
  <% end %>
135
132
 
@@ -1,6 +1,5 @@
1
1
  <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
2
2
  <h2 style="margin: 0;">Database Tables & Triggers</h2>
3
- <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-success" %>
4
3
  </div>
5
4
 
6
5
  <!-- Statistics -->
@@ -113,7 +112,6 @@
113
112
  </td>
114
113
  <td>
115
114
  <%= link_to "View Details", table_path(table[:table_name]), class: "btn btn-primary", style: "padding: 0.25rem 0.5rem; font-size: 0.875rem;" %>
116
- <%= link_to "Create Trigger", new_generator_path(pg_sql_triggers_generator_form: { table_name: table[:table_name] }), class: "btn btn-success", style: "padding: 0.25rem 0.5rem; font-size: 0.875rem; margin-top: 0.25rem; display: block;" %>
117
115
  </td>
118
116
  </tr>
119
117
  <% end %>
@@ -170,7 +168,6 @@
170
168
  <% end %>
171
169
  </p>
172
170
  <% if @filter == 'with_triggers' || @filter == 'all' %>
173
- <%= link_to "Generate New Trigger", new_generator_path, class: "btn btn-primary" %>
174
- <% end %>
171
+ <% end %>
175
172
  </div>
176
173
  <% end %>
@@ -1,7 +1,6 @@
1
1
  <div style="margin-bottom: 2rem;">
2
2
  <h2>Table: <%= @table_info[:table_name] %></h2>
3
3
  <%= link_to "← Back to Tables", tables_path, class: "btn", style: "background: #6c757d; color: white; text-decoration: none; margin-right: 1rem;" %>
4
- <%= link_to "Create Trigger for this Table", new_generator_path(pg_sql_triggers_generator_form: { table_name: @table_info[:table_name] }), class: "btn btn-success" %>
5
4
  </div>
6
5
 
7
6
  <!-- Table Information -->
@@ -138,7 +137,6 @@
138
137
  <% else %>
139
138
  <div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; border-radius: 4px;">
140
139
  <p style="margin: 0;">No registered triggers for this table.</p>
141
- <%= link_to "Create a trigger", new_generator_path(pg_sql_triggers_generator_form: { table_name: @table_info[:table_name] }), class: "btn btn-primary", style: "margin-top: 0.5rem;" %>
142
140
  </div>
143
141
  <% end %>
144
142
  </div>
data/config/routes.rb CHANGED
@@ -7,20 +7,6 @@ begin
7
7
 
8
8
  resources :tables, only: %i[index show]
9
9
 
10
- resources :generator, only: %i[new create] do
11
- collection do
12
- post :preview
13
- post :validate_table
14
- get :tables
15
- end
16
- end
17
-
18
- resources :sql_capsules, only: %i[new create show] do
19
- member do
20
- post :execute
21
- end
22
- end
23
-
24
10
  resources :migrations, only: [] do
25
11
  collection do
26
12
  post :up
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddForEachToPgSqlTriggersRegistry < ActiveRecord::Migration[6.1]
4
+ def change
5
+ add_column :pg_sql_triggers_registry, :for_each, :string, default: "row", null: false
6
+ add_index :pg_sql_triggers_registry, :for_each
7
+ end
8
+ end
@@ -11,6 +11,8 @@ Complete reference for using PgSqlTriggers programmatically from the Rails conso
11
11
  - [TriggerRegistry Model](#triggerregistry-model)
12
12
  - [Audit Log API](#audit-log-api)
13
13
 
14
+ > **Removed in 1.4.0**: `SQL::Capsule` and `SQL::Executor` have been removed. See [CHANGELOG](../CHANGELOG.md) for details.
15
+
14
16
  ## Registry API
15
17
 
16
18
  The Registry API provides methods for inspecting and managing triggers.
@@ -485,124 +487,6 @@ end
485
487
 
486
488
  **Returns**: Result of the block
487
489
 
488
- ## SQL Capsule API
489
-
490
- The SQL Capsule API provides emergency SQL execution capabilities with safety checks.
491
-
492
- ### `PgSqlTriggers::SQL::Capsule.new(name:, environment:, purpose:, sql:, created_at: nil)`
493
-
494
- Creates a new SQL capsule for emergency operations.
495
-
496
- ```ruby
497
- capsule = PgSqlTriggers::SQL::Capsule.new(
498
- name: "fix_user_permissions",
499
- environment: "production",
500
- purpose: "Emergency fix for user permission issue after deployment",
501
- sql: "UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';"
502
- )
503
- ```
504
-
505
- **Parameters**:
506
- - `name` (String): Unique name for the capsule (alphanumeric, underscores, hyphens only)
507
- - `environment` (String): Target environment (e.g., "production", "staging")
508
- - `purpose` (String): Description of what the capsule does and why (required for audit trail)
509
- - `sql` (String): The SQL statement(s) to execute
510
- - `created_at` (Time, optional): Creation timestamp (defaults to current time)
511
-
512
- **Raises**: `ArgumentError` if validation fails
513
-
514
- ### `capsule.checksum`
515
-
516
- Returns the SHA256 checksum of the SQL content.
517
-
518
- ```ruby
519
- capsule = PgSqlTriggers::SQL::Capsule.new(name: "fix", ...)
520
- puts capsule.checksum
521
- # => "a3f5b8c9d2e..."
522
- ```
523
-
524
- **Returns**: String (SHA256 hash)
525
-
526
- ### `capsule.to_h`
527
-
528
- Converts the capsule to a hash for storage or serialization.
529
-
530
- ```ruby
531
- capsule_data = capsule.to_h
532
- # => {
533
- # name: "fix_user_permissions",
534
- # environment: "production",
535
- # purpose: "Emergency fix...",
536
- # sql: "UPDATE users...",
537
- # checksum: "a3f5b8c9d2e...",
538
- # created_at: 2026-01-01 12:00:00 UTC
539
- # }
540
- ```
541
-
542
- **Returns**: Hash
543
-
544
- ## SQL Executor API
545
-
546
- The SQL Executor API handles safe execution of SQL capsules with comprehensive logging.
547
-
548
- ### `PgSqlTriggers::SQL::Executor.execute(capsule, actor:, confirmation: nil, dry_run: false)`
549
-
550
- Executes a SQL capsule with safety checks and logging.
551
-
552
- ```ruby
553
- capsule = PgSqlTriggers::SQL::Capsule.new(
554
- name: "emergency_fix",
555
- environment: "production",
556
- purpose: "Fix critical data corruption",
557
- sql: "UPDATE orders SET status = 'completed' WHERE id IN (123, 456);"
558
- )
559
-
560
- # Execute the capsule
561
- result = PgSqlTriggers::SQL::Executor.execute(
562
- capsule,
563
- actor: { type: "user", id: "admin@example.com" },
564
- confirmation: "EXECUTE SQL"
565
- )
566
-
567
- if result[:success]
568
- puts "SQL executed successfully"
569
- puts "Rows affected: #{result[:data][:rows_affected]}"
570
- else
571
- puts "Execution failed: #{result[:message]}"
572
- end
573
- ```
574
-
575
- **Parameters**:
576
- - `capsule` (Capsule): The SQL capsule to execute
577
- - `actor` (Hash): Information about who is executing (Admin permission required)
578
- - `confirmation` (String, optional): Kill switch confirmation text
579
- - `dry_run` (Boolean): If true, validates without executing (default: false)
580
-
581
- **Returns**: Hash with `:success`, `:message`, and `:data` keys
582
-
583
- **Raises**: Permission and kill switch errors are returned in the result hash
584
-
585
- ### `PgSqlTriggers::SQL::Executor.execute_capsule(capsule_name, actor:, confirmation: nil, dry_run: false)`
586
-
587
- Executes a previously stored SQL capsule by name from the registry.
588
-
589
- ```ruby
590
- # Execute a capsule stored in the registry
591
- result = PgSqlTriggers::SQL::Executor.execute_capsule(
592
- "emergency_fix",
593
- actor: { type: "user", id: "admin@example.com" },
594
- confirmation: "EXECUTE SQL"
595
- )
596
- ```
597
-
598
- **Parameters**:
599
- - `capsule_name` (String): Name of the capsule in the registry
600
- - `actor` (Hash): Information about who is executing
601
- - `confirmation` (String, optional): Kill switch confirmation text
602
- - `dry_run` (Boolean): If true, validates without executing
603
-
604
- **Returns**: Hash with execution results
605
-
606
490
  ## DSL API
607
491
 
608
492
  The DSL API is used to define triggers in your application.
@@ -616,10 +500,10 @@ PgSqlTriggers::DSL.pg_sql_trigger "users_email_validation" do
616
500
  table :users
617
501
  on :insert, :update
618
502
  function :validate_user_email
619
- version 1
620
- enabled false
503
+ self.version = 1
504
+ self.enabled = true
621
505
  timing :before
622
- when_env :production
506
+ for_each_row
623
507
  end
624
508
  ```
625
509
 
@@ -651,7 +535,7 @@ on :insert, :update, :delete
651
535
  ```
652
536
 
653
537
  **Parameters**:
654
- - `events` (Symbols): One or more of `:insert`, `:update`, `:delete`
538
+ - `events` (Symbols): One or more of `:insert`, `:update`, `:delete`, `:truncate`
655
539
 
656
540
  #### `function(function_name)`
657
541
 
@@ -664,37 +548,48 @@ function :validate_user_email
664
548
  **Parameters**:
665
549
  - `function_name` (Symbol): Function name
666
550
 
667
- #### `version(number)`
551
+ #### `self.version = number`
668
552
 
669
553
  Sets the trigger version.
670
554
 
671
555
  ```ruby
672
- version 1
673
- version 2
556
+ self.version = 1 # Increment when trigger logic changes
557
+ self.version = 2
674
558
  ```
675
559
 
676
560
  **Parameters**:
677
561
  - `number` (Integer): Version number
678
562
 
679
- #### `enabled(state)`
563
+ #### `self.enabled = state`
680
564
 
681
- Sets the initial enabled state.
565
+ Sets the initial enabled state. Defaults to `true`.
682
566
 
683
567
  ```ruby
684
- enabled true
685
- enabled false
568
+ self.enabled = true # Trigger is active (default)
569
+ self.enabled = false # Trigger is inactive
686
570
  ```
687
571
 
688
572
  **Parameters**:
689
573
  - `state` (Boolean): Initial state
690
574
 
575
+ #### `for_each_row` / `for_each_statement`
576
+
577
+ Specifies PostgreSQL execution granularity. Defaults to `for_each_row`.
578
+
579
+ ```ruby
580
+ for_each_row # FOR EACH ROW (default)
581
+ for_each_statement # FOR EACH STATEMENT
582
+ ```
583
+
584
+ Stored in the `for_each` column on the registry table and included in checksum calculation.
585
+
691
586
  #### `when_env(*environments)`
692
587
 
693
- Restricts trigger to specific environments.
588
+ **Deprecated.** Restricts trigger to specific environments. Emits a deprecation warning on every call and will be removed in a future major version. Use application-level configuration to gate trigger behaviour by environment instead.
694
589
 
695
590
  ```ruby
696
- when_env :production
697
- when_env :production, :staging
591
+ when_env :production # Deprecated — avoid in new code
592
+ when_env :production, :staging # Deprecated — avoid in new code
698
593
  ```
699
594
 
700
595
  **Parameters**:
@@ -712,8 +607,6 @@ timing :after # Trigger fires after constraint checks
712
607
  **Parameters**:
713
608
  - `timing_value` (Symbol or String): Either `:before` or `:after`
714
609
 
715
- **Returns**: Current timing value if called without argument
716
-
717
610
  ## TriggerRegistry Model
718
611
 
719
612
  The `TriggerRegistry` ActiveRecord model represents a trigger in the registry.
@@ -728,12 +621,13 @@ trigger.table_name # => "users"
728
621
  trigger.function_name # => "validate_user_email"
729
622
  trigger.events # => ["insert", "update"]
730
623
  trigger.version # => 1
731
- trigger.enabled # => false
732
- trigger.timing # => "before" or "after"
733
- trigger.environments # => ["production"]
734
- trigger.condition # => "NEW.status = 'active'" or nil
735
- trigger.created_at # => 2023-12-15 12:00:00 UTC
736
- trigger.updated_at # => 2023-12-15 12:00:00 UTC
624
+ trigger.enabled # => true
625
+ trigger.timing # => "before" or "after"
626
+ trigger.for_each # => "row" or "statement"
627
+ trigger.environments # => [] (when_env is deprecated)
628
+ trigger.condition # => "NEW.status = 'active'" or nil
629
+ trigger.created_at # => 2023-12-15 12:00:00 UTC
630
+ trigger.updated_at # => 2023-12-15 12:00:00 UTC
737
631
  ```
738
632
 
739
633
  ### Instance Methods
@@ -816,7 +710,7 @@ trigger.re_execute!(
816
710
  - `confirmation` (String, optional): Kill switch confirmation text
817
711
  - `actor` (Hash, optional): Information about who is performing the operation
818
712
 
819
- **Raises**: `ArgumentError` if reason is blank or function_body is missing, `PgSqlTriggers::KillSwitchError`, `StandardError`
713
+ **Raises**: `ArgumentError` if reason is blank, `PgSqlTriggers::KillSwitchError`, `StandardError`
820
714
 
821
715
  **Returns**: `true` on success
822
716
 
@@ -904,10 +798,6 @@ triggers = PgSqlTriggers::Registry.list
904
798
  puts "Total triggers: #{triggers.count}"
905
799
 
906
800
  # 4. Check for drift
907
- drift = PgSqlTriggers::Registry.diff
908
- puts "Drifted triggers: #{drift[:drifted].count}"
909
-
910
- # Alternative: Use dedicated query methods
911
801
  drifted = PgSqlTriggers::Registry.drifted
912
802
  in_sync = PgSqlTriggers::Registry.in_sync
913
803
  unknown = PgSqlTriggers::Registry.unknown_triggers
@@ -955,16 +845,16 @@ end
955
845
  ### Inspection and Reporting
956
846
 
957
847
  ```ruby
958
- # Generate a drift report
959
- drift = PgSqlTriggers::Registry.diff
960
-
848
+ # Generate a drift report using dedicated query methods
961
849
  puts "=== Drift Report ==="
962
- puts "In Sync: #{drift[:in_sync].count}"
963
- puts "Drifted: #{drift[:drifted].count}"
850
+ puts "In Sync: #{PgSqlTriggers::Registry.in_sync.count}"
851
+ puts "Drifted: #{PgSqlTriggers::Registry.drifted.count}"
852
+ puts "Dropped: #{PgSqlTriggers::Registry.dropped.count}"
853
+ puts "Unknown: #{PgSqlTriggers::Registry.unknown_triggers.count}"
854
+
855
+ # Or get the full categorised hash
856
+ drift = PgSqlTriggers::Registry.diff
964
857
  puts "Manual Override: #{drift[:manual_override].count}"
965
- puts "Disabled: #{drift[:disabled].count}"
966
- puts "Dropped: #{drift[:dropped].count}"
967
- puts "Unknown: #{drift[:unknown].count}"
968
858
 
969
859
  # List all triggers with details
970
860
  triggers = PgSqlTriggers::Registry.list
@@ -976,6 +866,7 @@ triggers.each do |trigger|
976
866
  puts " Function: #{trigger.function_name}"
977
867
  puts " Events: #{trigger.events.join(', ')}"
978
868
  puts " Timing: #{trigger.timing}"
869
+ puts " For Each: #{trigger.for_each}"
979
870
  puts " Version: #{trigger.version}"
980
871
  puts " Enabled: #{trigger.enabled}"
981
872
  puts " Drift: #{trigger.drift_status}"
@@ -50,6 +50,25 @@ config.default_environment = -> {
50
50
  config.default_environment = -> { 'production' }
51
51
  ```
52
52
 
53
+ ### `db_schema`
54
+
55
+ The PostgreSQL schema in which triggers are managed. All system catalog queries use this value. Override when your triggers live in a non-`public` schema.
56
+
57
+ - **Type**: String
58
+ - **Default**: `"public"`
59
+
60
+ ```ruby
61
+ config.db_schema = "public" # default
62
+
63
+ # Use a custom schema
64
+ config.db_schema = "app"
65
+ ```
66
+
67
+ You can also set this at runtime:
68
+ ```ruby
69
+ PgSqlTriggers.db_schema = "app"
70
+ ```
71
+
53
72
  ### `mount_path`
54
73
 
55
74
  Customize where the web UI is mounted (configured in routes, not initializer).
@@ -192,6 +211,8 @@ Custom authorization logic for the web UI and API.
192
211
  - **Returns**: Boolean
193
212
  - **Default**: `->(_actor, _action, _environment) { true }`
194
213
 
214
+ > **Important**: If `permission_checker` is `nil` (not configured) and the app boots in **production**, the engine emits a `Rails.logger.warn` at startup. Configure a real checker before deploying to production.
215
+
195
216
  ```ruby
196
217
  # Default: allow all (development only!)
197
218
  config.permission_checker = ->(_actor, _action, _environment) { true }
@@ -210,9 +231,9 @@ config.permission_checker = ->(actor, action, environment) {
210
231
  case action
211
232
  when :view_triggers, :view_diffs
212
233
  user.present? # Viewer level
213
- when :enable_trigger, :disable_trigger, :apply_trigger, :generate_trigger, :test_trigger, :dry_run_sql
234
+ when :enable_trigger, :disable_trigger, :apply_trigger, :test_trigger
214
235
  user.operator? || user.admin? # Operator level
215
- when :drop_trigger, :execute_sql, :override_drift
236
+ when :drop_trigger, :override_drift
216
237
  user.admin? # Admin level
217
238
  else
218
239
  false
@@ -287,7 +308,7 @@ The permission checker should handle three levels:
287
308
  #### `:admin`
288
309
  - All `:operate` permissions
289
310
  - Drop triggers
290
- - Execute SQL capsules
311
+ - Override drift
291
312
  - Modify registry directly
292
313
 
293
314
  ## Environment Detection