# frozen_string_literal: false # @!parse # class ActiveRecord::Migration # # Create a trigger for a table # # # # @param [#to_s] table (nil) The qualified name of the table # # @param [#to_s] name (nil) The name of the trigger # # @option options [Boolean] :replace_existing (false) If the trigger should overwrite an existing one # # @option options [#to_s] :function (nil) The qualified name of the function to be called # # @option options [Symbol] :type (nil) When the trigger should be run # # Supported values: :before, :after, :instead_of # # @option options [Array] :events List of events running the trigger # # Supported values in the array: :insert, :update, :delete, :truncate # # @option options [Boolean] :constraint (false) If the trigger is a constraint # # @option options [Symbol] :initially (:immediate) If the constraint check should be deferred # # Supported values: :immediate (default), :deferred # # @option options [#to_s] :when (nil) The SQL snippet definiing a condition for the trigger # # @option options [Symbol] :for_each (:statement) Define if a trigger should be run for every row # # Supported values: :statement (default), :row # # @option options [#to_s] :comment (nil) The commend describing the trigger # # @yield [t] the block with the trigger's definition # # @yieldparam Object receiver of methods specifying the trigger # # @return [void] # # # # The trigger can be created either using inline syntax # # # # ```ruby # # create_trigger "users", "do_something", # # function: "do_something()", # # for_each: :row, # # type: :after, # # events: %i[insert update], # # comment: "Does something useful" # # ``` # # # # or using a block: # # # # ```ruby # # create_trigger do |t| # # t.table "users" # # t.name "do_something" # # t.function "do_something()" # # t.for_each :row # # t.type :after # # t.events %i[insert update] # # t.comment "Does something useful" # # end # # ``` # # # # With a `replace_existing: true` option, # # it will be created using the `CREATE OR REPLACE` clause. # # (Available in PostgreSQL v14+). # # # # ```ruby # # create_trigger "users", "do_something", # # function: "do_something()", # # type: :after, # # events: %i[insert update], # # replace_previous: true # # ``` # # # # In this case the migration is irreversible because we # # don't know if and how to restore its previous definition. # def create_trigger(table, name = nil, **options, &block); end # end module PGTrunk::Operations::Triggers # @private class CreateTrigger < Base validates :function, :type, :events, presence: true validates :if_exists, :new_name, absence: true from_sql do |_version| <<~SQL WITH t AS ( SELECT t.oid, t.tgname AS name, ( CASE WHEN t.tgconstraint != 0 THEN true END ) AS constraint, ( CASE WHEN t.tgdeferrable AND t.tginitdeferred THEN 'deferred' WHEN t.tgdeferrable AND NOT t.tginitdeferred THEN 'immediate' END ) AS "initially", pg_get_triggerdef(t.oid, true) AS snippet, ( CASE WHEN (t.tgtype::int::bit(7) & b'0000001')::int = 0 THEN 'statement' ELSE 'row' END ) AS for_each, ( SELECT array_agg(attname) FROM ( SELECT a.attname FROM unnest(t.tgattr) col(num) JOIN pg_attribute a ON a.attnum = col.num WHERE a.attrelid = t.tgrelid ) list ) AS columns, ( CASE WHEN ((tgtype::int::bit(7) & b'0000010')::int != 0) THEN 'before' WHEN ((tgtype::int::bit(7) & b'0000010')::int = 0) THEN 'after' ELSE 'instead_of' END ) AS type, array_remove( ARRAY[ (CASE WHEN (tgtype::int::bit(7) & b'0000100')::int != 0 THEN 'insert' END), (CASE WHEN (tgtype::int::bit(7) & b'0001000')::int != 0 THEN 'delete' END), (CASE WHEN (tgtype::int::bit(7) & b'0010000')::int != 0 THEN 'update' END), (CASE WHEN (tgtype::int::bit(7) & b'0100000')::int != 0 THEN 'truncate' END) ]::text[], NULL ) AS events, (c.relnamespace::regnamespace || '.' || c.relname) AS "table", (f.pronamespace::regnamespace || '.' || f.proname || '()') AS function, d.description AS comment FROM pg_trigger t JOIN pg_proc f ON f.oid = t.tgfoid JOIN pg_class c ON c.oid = t.tgrelid LEFT JOIN pg_description d ON d.objoid = t.oid ) SELECT oid, name, "table", function, "constraint", "initially", for_each, ( CASE WHEN regexp_match(snippet, 'WHEN') IS NOT NULL THEN regexp_replace( regexp_replace(snippet, '^.+WHEN [(]', ''), '[)] EXECUTE.+', '' ) END ) AS "when", type, events, columns, comment FROM t SQL end def to_sql(version) [ create_trigger(version), *create_comment, register_trigger, ].join(" ") end def invert irreversible!("replace_existing: true") if replace_existing DropTrigger.new(**to_h) end private def create_trigger(version) sql = "CREATE" sql << " OR REPLACE" if replace_existing && version >= "14" sql << " CONSTRAINT" if constraint sql << " TRIGGER #{name.name.inspect}" sql << " BEFORE #{events_sql}" if type == :before sql << " AFTER #{events_sql}" if type == :after sql << " INSTEAD OF #{events_sql}" if type == :instead_of sql << " ON #{table.to_sql}" sql << " DEFERRABLE" if initially.present? sql << " INITIALLY DEFERRED" if initially == :deferred sql << " FOR EACH ROW" if for_each&.== :row sql << " WHEN (#{self.when})" if self.when.present? sql << " EXECUTE PROCEDURE #{function.to_sql(true)};" end def create_comment return unless comment <<~SQL.squish COMMENT ON TRIGGER #{name.name.inspect} ON #{table.to_sql} IS $comment$#{comment}$comment$; SQL end def register_trigger <<~SQL.squish INSERT INTO pg_trunk (oid, classid) SELECT t.oid, 'pg_trigger'::regclass FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid WHERE c.relname = #{table.quoted} AND c.relnamespace = #{table.namespace} AND t.tgname = #{name.quoted} ON CONFLICT DO NOTHING; SQL end def events_sql events.map do |event| if event == :update && columns.present? "UPDATE OF #{columns.join(', ')}" else event.to_s.upcase end end.join(" OR ") end end end