PostgreSQL triggers in Rails
SQL triggers is something that everybody heard a lot about. Before start we need to talk about benefits and burdens.
###+
- It’s very very fast. If you are using any kind of adapter you pay for all this wrappers and connectors. If it’s trigger – all operations are very cheap because they are provided by PostgtreSQL.
- It works like a magick. There is no code on your repository, there is no any callbacks on your models and so on.
###-
- It works like a magick. You can’t easy determine all operations. All this
\df+stuff and so on. - It’s hard to change. Really if you want change trigger you need to drop it and hang it again. With full trigger code. You can use hair_trigger to simplify it but anyway.
- It increases requirements to your developers. Really, thay should now SQL. In perfect world your team is awesome. But it’s business.
- It will cause some problems in cause if you are using STI because you can’t define model by it’s tablename.
- It can cause problems on backups restoring. On restoring large tables somebody can use
--disable-triggersto speed up process and it’s not that you are expecting.
If you are still want it let’s continue. You have MobilePhone, Laptop and Ad. Ad contains info about Product (in this case MobilePhone or Laptop). You want to provide some kind of faceted search for your products. We will use extension pg_trgm for searching products with name similar to provided by user.
What we need to implement it?
1) pg_trgm extension.
CREATE EXTENSION IF NOT EXISTS pg_trgm;2) Table to store all this stuff
CREATE TABLE IF NOT EXISTS product_names (
product_name text NOT NULL,
product_type text NOT NULL,
product_id integer NOT NULL
);
CREATE INDEX product_name_gin_index ON product_names
USING gin(product_name gin_trgm_ops);3) Function that will process data. Based on this example. It’s pretty simple. We change our table curresponding to actions on product`s tables.
CREATE OR REPLACE FUNCTION collect_product_names() RETURNS TRIGGER AS $body$
BEGIN
IF (TG_OP = 'INSERT') THEN
INSERT INTO product_names (product_type, product_id, product_name)
VALUES (TG_TABLE_NAME::TEXT, NEW.id, NEW.name);
RETURN NULL;
ELSIF (TG_OP = 'UPDATE') THEN
UPDATE product_names
SET product_name = NEW.name
WHERE product_id = OLD.id
AND product_type = TG_TABLE_NAME::TEXT;
RETURN NULL;
ELSIF (TG_OP = 'DELETE') THEN
DELETE
FROM product_names
WHERE product_type = TG_TABLE_NAME::TEXT
AND product_id = OLD.id;
RETURN NULL;
ELSE
RAISE WARNING '[AUDIT.collect_product_names] - Other action occurred: %, at %',TG_OP,now();
RETURN NULL;
END IF;
END;
$body$
LANGUAGE plpgsql
SECURITY DEFINER;4) Add our function for product`s table
DROP TRIGGER IF EXISTS collect_product_names ON mobile_phones;
DROP TRIGGER IF EXISTS collect_product_names ON laptops;
CREATE TRIGGER collect_product_names
AFTER INSERT OR DELETE OR UPDATE ON mobile_phones
FOR EACH ROW EXECUTE PROCEDURE collect_product_names();
CREATE TRIGGER collect_product_names
AFTER INSERT OR DELETE OR UPDATE ON laptops
FOR EACH ROW EXECUTE PROCEDURE collect_product_names();5) Migrate existing data to our new table
[MobilePhone, Laptop].each do |klass|
klass.pluck(:name, :id).each do |(name, id)|
ProductName.create!(product_name: name,
product_id: id, product_type: klass.table_name)
end
endAll in one
class AddProductNamesTrigger < ActiveRecord::Migration
def up
execute %Q{
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE IF NOT EXISTS product_names (
product_name text NOT NULL,
product_type text NOT NULL,
product_id integer NOT NULL
);
CREATE INDEX product_name_gin_index ON product_names USING gin(product_name gin_trgm_ops);
CREATE OR REPLACE FUNCTION collect_product_names() RETURNS TRIGGER AS $body$
BEGIN
IF (TG_OP = 'INSERT') THEN
INSERT INTO product_names (product_type, product_id, product_name)
VALUES (TG_TABLE_NAME::TEXT, NEW.id, NEW.name);
RETURN NULL;
ELSIF (TG_OP = 'UPDATE') THEN
UPDATE product_names
SET product_name = NEW.name
WHERE product_id = OLD.id
AND product_type = TG_TABLE_NAME::TEXT;
RETURN NULL;
ELSIF (TG_OP = 'DELETE') THEN
DELETE
FROM product_names
WHERE product_type = TG_TABLE_NAME::TEXT
AND product_id = OLD.id;
RETURN NULL;
ELSE
RAISE WARNING '[AUDIT.collect_product_names] - Other action occurred: %, at %',TG_OP,now();
RETURN NULL;
END IF;
END;
$body$
LANGUAGE plpgsql
SECURITY DEFINER;
DROP TRIGGER IF EXISTS collect_product_names ON mobile_phones;
DROP TRIGGER IF EXISTS collect_product_names ON laptops;
CREATE TRIGGER collect_product_names
AFTER INSERT OR DELETE OR UPDATE ON mobile_phones
FOR EACH ROW EXECUTE PROCEDURE collect_product_names();
CREATE TRIGGER collect_product_names
AFTER INSERT OR DELETE OR UPDATE ON laptops
FOR EACH ROW EXECUTE PROCEDURE collect_product_names();
}
[MobilePhone, Laptop].each do |klass|
klass.pluck(:name, :id).each do |(name, id)|
ProductName.create(product_name: name, product_id: id, product_type: klass.table_name)
end
end
end
def down
execute %Q{
DROP TABLE IF EXISTS product_names;
DROP TRIGGER IF EXISTS collect_product_names ON mobile_phones;
DROP TRIGGER IF EXISTS collect_product_names ON laptops;
DROP FUNCTION IF EXISTS collect_product_names();
}
end
endOur databases prepared. Now we need to prepare rails app.
class ProductName < ActiveRecord::Base
scope :by_name, -> (pattern) { where("product_name % ?", pattern) }
scope :statistics, -> { group(:product_type).count }
endHow to use it?
ProductName.by_name("Apple").statistics
#=> {"laptops"=>1, "mobile_phones"=>3}P.S. Now you can see your function with \df+ on PostgreSQL terminal.