PostgreSQL best way to model position within scope of foreign key - postgresql

I have boxes that contain books. The books table has a foreign key called "box_id", referencing the "id" of the box to which it belongs. I need a way to keep track of the relative position of each book within its box. A book can be removed from its box regardless of its position inside the box, but when a book is put into a box, it always goes in last. A book can be moved from one box to another. The order of books inside a box cannot be changed. What's the best way to model this? I prefer not to have a third table.

Put a sort_order column in the books table and use that in an ORDER BY clause when you query the books in a box.
You would update the sort_order every time you move a book from one box to another (or, put it in a box the first time) with a value larger than the other sort_order values in that box - or, for simplicity, with a value larger than all sort_order values in the entire table. For that, you can use a sequence or the current_time() (assuming you don't move multiple books into the same box in the same transaction). The update can be done either by the client code or using an ON INSERT/UPDATE trigger.

The normalized way would be to create a third table including box_id, book_id and book_position_in_box columns.
A denormalized way can be to create a book_id_array column in table box because the array types keep the order of the items in the arrays.
Then you can automatically update this array type column by trigger functions ON INSERT, ON UPDATE, ON DELETE defined on table books, like :
CREATE FUNCTION box_insert_update() RETURNS TRIGGER LANGUAGE plpgsql AS
$$
BEGIN
UPDATE box
SET book_id_array = book_id_array || array[NEW.id] -- book added at the last place in the NEW box
WHERE id = NEW.box_id ;
RETURN NEW ;
END ;
$$ ;
CREATE TRIGGER box_insert_update BEFORE INSERT OR UPDATE ON book
FOR EACH ROW EXECUTE FUNCTION box_insert_update() ;
CREATE FUNCTION box_update_delete() RETURNS TRIGGER LANGUAGE plpgsql AS
$$
BEGIN
UPDATE box
SET book_id_array = array_remove(book_id_array,OLD.id) -- book is removed from the OLD box with no gap
WHERE id = OLD.box_id ;
RETURN OLD ;
END ;
$$ ;
CREATE TRIGGER box_update_delete AFTER UPDATE OR DELETE ON book
FOR EACH ROW EXECUTE FUNCTION box_update_delete() ;
Full test sample & result in dbfiddle.

Related

create a custom ON DELETE action when a foreign key is deleted in Postgres

I have 2 tables: a group table and element table to manage similar elements into groups.
CREATE TABLE groups (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
main_element UUID NOT NULL REFERENCES elements(id)
);
CREATE TABLE elements (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
group_id UUID NOT NULL REFERENCES groups(id)
group_similarity NUMERIC DEFAULT 1 CHECK (group_relevance between 0 and 1)
);
Every group has a main element that the rest of the elements are deemed to be similar too. So you can have many elements in a group because those elements are all above some similarity metric when compared to the main element of that group.
This is easy to build for element and group creation. My issue is regarding element and group deletion.
If a main_element gets deleted, I need to recreate that group. I am thinking I will just use the next most similar element as the new main and recalculate similarities to that one.
How can I do that with triggers and constraints?
I want the trigger to run only when a main_element is deleted, which is why I was thinking a custom ON DELETE action would be best if possible. I don't need the trigger to run every time any element is deleted.
Assuming that the main element in a group isn't necessarily the only one having group_similarity = 1 (the main element's duplicate under a different id):
CREATE FUNCTION pick_new_main_element_trigger()
RETURNS TRIGGER LANGUAGE plpgsql AS
$$ DECLARE
new_main_element_id uuid;
new_highest_similarity numeric;
BEGIN
if old.id <> (select main_element from groups where id=old.group_id)
return null;--Not the main element, so trigger shouldn't have fired, so aborting
end if;
select max(group_similarity) into new_highest_similarity
from elements
where group_id=old.group_id
and id<>old.id;
select id into new_main_element_id
from elements
where group_similarity=new_highest_similarity
limit 1;--if there are more than one second bests, random one gets picked
update groups g
set main_element=new_main_element_id
where g.id=old.group_id;
update elements --equal shift towards 1, by the best one's distance to 1
set group_similarity=group_similarity+1-new_highest_similarity
where group_id=old.group_id
and id<>old.id
and id<>new_main_element_id;
update elements--new main element is 100% self-similar
set group_similarity=1
where group_id=old.group_id
and id<>old.id
and id=new_main_element_id;
RETURN OLD;
END $$;
You can narrow down when this trigger fires using WHEN condition. You'll still have some false positives but they should hit the failsafe IF at the beginning of the function above.
CREATE TRIGGER pick_new_main_element
BEFORE DELETE ON elements
FOR EACH ROW
WHEN (old.group_similarity=1)
EXECUTE PROCEDURE pick_new_main_element_trigger();
Online demo.
In the presented structure, groups table seems not entirely necessary, or at least not used this way. Since a group always has a single main element, you can just consider the main element id the group_id - point from each element to its group's main element.
You save a table.
You can make the trigger fire exclusively when the main element is being deleted by checking right in the trigger WHEN condition id=main_element_id AND group_similarity=1.
Inserts are simpler because you get rid of a circular reference.
If deleting the main element forces you to update each element with recalculated group_similarity, then updating their group_id as well doesn't add much overhead. Otherwise you might have wanted to keep groups abstracted so that when the main element changes, only the groups record requires an update, while all records in elements keep pointing at it.

Function to Automatically set number of corresponding records in one table as default value in second table

I'm kind of stuck here. I'll try to keep it simple.
I have two tables.
Products (product_id, number_of_reviews, ...)
Reviews (main_product_id, review, ...)
main_product_id in Reviews table is the foreign key referencing product_id of Products table.
I need that the number_of_reviews column should automatically update to the number of reviews for that product_id present in the Reviews table. Match can be made by comparing product_id with main_product_id.
I know that I can use count to get number of reviews using this sql statement like:
SELECT COUNT(*) FROM reviews WHERE main_product_id = 'exampleid1'
Here exampleid1 should be product_id from products table.
But how do I create the function that I can call for DEFAULT in column number_of_reviews? Function that automatically takes the product_id from current row and passes it to that select statement and return the number of reviews...
I'm just so stuck here from hours, did a lot of searching but I can't figure it out.
It is my first time asking a question here on stackoverflow and my first time I'm taking interest in coding. PERN stack to be specific.
(I didn't like code for more than 6 years but now finally i built some interest)
First off this is actually a bad plan, you are saving a value the can easily be calculated. However, it seems quite common even though it often leads to complications. The function you need is a trigger; more specifically a trigger function and a trigger on reviews. (see demo)
create or replace function record_a_review_air()
returns trigger
language plpgsql
as $$
begin
update products
set reviews = reviews+1
where prod_id = new.prod_id;
return new;
end;
$$;
create trigger reviews_air
after insert
on reviews
for each row
execute function record_a_review_air();
NOTE: Setting a DEFAULT will not accomplish what you want. Doing so would set the a value when the Product is inserted. But would never be invoked again for that Product.
Here is what worked for me - Thanks to the demo and code provided by #belayer
create or replace function record_a_review_air()
returns trigger
language plpgsql
as $$
begin
update products
set n_reviews = (SELECT COUNT(*) FROM reviews WHERE main_prod_id = prod_id)
where prod_id = coalesce(new.main_prod_id, old.main_prod_id);
return new;
end;
$$;
create trigger reviews_air
after insert or delete or update of rev_id, main_prod_id, review
on reviews
for each row
execute function record_a_review_air();
(I updated the answer to add the new code that works for me on events(insert, update, delete) )

PostgreSQL trigger to update field when other field in the same table is updated

I'm fairly new to SQL triggers and I'm struggling whit this:
I have a table that stores data about properties and their prices:
id_property price area price_m2
1 1500 60 25
2 3500 85 41
The clients could change the price of their property often, but the area won't. So I want to create a trigger that updates the price_m2 column when the price column is updated.
I've tried something like that or similar variations:
First create the function
CREATE FUNCTION update_precio_m2() RETURNS TRIGGER
AS
$$
BEGIN
update my_table
set price_m2 = new.price/old.area
where (old.id = new.id);
RETURN new;
end
$$
language plpgsql;
Then create the trigger
CREATE TRIGGER update_price_m2
AFTER UPDATE ON my_table
FOR EACH ROW
WHEN (
old.price IS DISTINCT FROM new.price
)
EXECUTE PROCEDURE update_price_m2();
But when I change the price I got unexpected results, like the price_m2 column change for various id's when I only want to change the value for one id (the one who changed).
Note
I know it's an antipattern to store columns whose value depends on the operation between two other columns, but there is a reason for that in this case
Thanks!
Just to follow up on this question so it can be closed, my recommendation in the comments was to use a generated column, which have been available since postgres 12:
https://www.postgresql.org/docs/current/ddl-generated-columns.html
The syntax would be something like this:
CREATE TABLE my_table (
id_property bigint GENERATED ALWAYS AS IDENTITY,
price int,
area int,
price_m2 int GENERATED ALWAYS AS (price / area) STORED
);

postgres make a records chain in one table

I have a table in postgres 9.4 and I need to do the following:
When new inserted record comes, I need to find previous records with given parameters and assign it's 'next_message' column value to newly generated record. So I want each record had reference to the next one with given filter, for example 'session_id'. So, if session_id=5, all records with seesion_id=5 should reference next one. I created a trigger that selects previous record and set this field. But it's bad and it will not work in highly loaded db table. How to do that?
That's my trigger:
CREATE OR REPLACE FUNCTION "public"."messages_compute_next_message" () RETURNS trigger
VOLATILE
AS $dbvis$
DECLARE previous_session_message integer;
BEGIN
/*NEW.next_message_id=NEW.id;*/
update message set next_message_id=NEW.id where id=(select max(c.id) from message c where c.session_id=NEW.session_id and c.id<>NEW.id);
RETURN NEW;
END
$dbvis$ LANGUAGE plpgsql
If I post records too frequently, I get many null values in next_message_id fields. And it's logical, otherwise I have to block entire table on every insert. How to do that properly in postgres?
I'd say forget about next_message_id.
The way your trigger looks, it seems to me that messages are ordered by id within one session.
So if you need the previous message for the message with id 42, you can find it with
SELECT max(prev.id)
FROM message prev JOIN message curr
ON prev.session_id = curr.session_id
AND prev.id < curr.id
WHERE curr.id = 42;
Setting the right indexes will speed this up considerably.
I assume there already is an index on id; maybe a second index ON (session_id, id) will help.

How do I make a trigger to update a column in another table?

So I am working on adding a last updated time to the database for my app's server. The idea is that it will record the time an update is applied to one of our trips and then the app can send a get request to figure out if it's got all of the correct up to date information.
I've added the column to our table, and provided the service for it all, and finally manage to get a trigger going to update the column every time a change is made to a trip in it's trip table. My problem now comes from the fact that the information that pertains to a trip is stored across a multitude of other tables as well (for instance, there are tables for the routes that make up a trip and the photos that a user can see on the trip, etc...) and if any of that data changes, then the trip's update time also needs to change. I can't for the life of me figure out how to set up the trigger so that when I change some route information, the last updated time for the trip(s) the route belongs to will be updated in it's table.
This is my trigger code as it stands now: it updates the trip table's last updated column when that trip's row is updated.
CREATE OR REPLACE FUNCTION record_update_time() RETURNS TRIGGER AS
$$
BEGIN
NEW.last_updated=now();
RETURN NEW;
END;
$$
LANGUAGE PLPGSQL;
CREATE TRIGGER update_entry_on_entry_change
BEFORE UPDATE ON mydatabase.trip FOR EACH ROW
EXECUTE PROCEDURE record_update_time();
--I used the next two queries just to test that the trigger works. It
--probably doesn't make a difference to you but I'll keep it here for reference
UPDATE mydatabase.trip
SET title='Sample New Title'
WHERE id = 2;
SELECT *
FROM mydatabase.trip
WHERE mydatabase.trip.id < 5;
Now I need it to update when the rows referencing the trip row with a foreign key get updated. Any ideas from someone more experienced with SQL triggers than I?
"mydatabase" is a remarkably unfortunate name for a schema.
The trigger function could look like this:
CREATE OR REPLACE FUNCTION trg_upaft_upd_trip()
RETURNS trigger
LANGUAGE plpgsql AS
$func$
BEGIN
UPDATE mydatabase.trip t -- "mydatabase" = schema name (?!)
SET last_updated = now()
WHERE t.id = NEW.trip_id -- guessing column names
RETURN NULL; -- calling this AFTER UPDATE
END
$func$;
And needs to be used in a trigger on every related table (not on trip itself):
CREATE TRIGGER upaft_upd_trip
AFTER UPDATE ON mydatabase.trip_detail
FOR EACH ROW EXECUTE PROCEDURE trg_upaft_upd_trip();
You also need to cover INSERT and DELETE (and possibly COPY) on all sub-tables ...
This approach has many potential points of failure. As alternative, consider a query or view that computes the latest last_updated from sub-tables dynamically. If you update often this might be the superior approach.
If you rarely UPDATE and SELECT often, your first approach might pay.