loading...

Use your database! (part 2)

livioribeiro profile image Livio Ribeiro ・3 min read

In the previous post, I showed a simple example on using a database trigger to set the "updated" field when data changes. Now I will show a more complex example.

Let's take a schema for a task list:

CREATE TABLE IF NOT EXISTS tasklist (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100)
);

CREATE TABLE IF NOT EXISTS task (
    tasklist_id INTEGER NOT NULL,
    name VARCHAR(100) NOT NULL,
    "order" INTEGER NOT NULL CHECK ("order" > 0),

    PRIMARY KEY (tasklist_id, name),
    FOREIGN KEY (tasklist_id) REFERENCES tasklist (id)
);

Here we have two tables, tasklist and task, the latter containing a column called order, which is, obviously, an order number for the tasks of a specific list.

Now starts our problems: To insert a new task, we need to fetch the max order number for the list, increment by 1, and insert the new task. But what will happen when two people try to insert a task at the same time?

Another problem is changing the order number of a task, because you need to shift the order of the others tasks. Deleting a task has the same issue.

To solve these problems, we can tell the database to set the order number when inserting a new task and shift the order of the tasks when a task is updated or deleted.

First, let's create a database function to help us:

CREATE OR REPLACE FUNCTION task_shift_order(
    list_id INTEGER,
    new_order INTEGER,
    old_order INTEGER
    ) RETURNS INTEGER AS $$
DECLARE
    max_order INTEGER;
BEGIN
    -- if the task table is empty, max("order") will return null
    -- use coalesce to return 0 instead of null
    SELECT coalesce(max("order"), 0) INTO max_order
    FROM task
    WHERE tasklist_id = list_id;

    -- do not allow order number greater then max order
    IF new_order > max_order THEN
        RAISE EXCEPTION 'New order (%) exceeds number of tasks (%) in tasklist(id = %)', new_order, max_order, list_id;
    END IF;

    IF new_order IS NULL THEN
    -- task is being inserted without order
        new_order := max_order + 1;
    ELSIF old_order IS NULL THEN
    -- task is being inserted at specific order
        UPDATE task SET "order" = "order" + 1
        WHERE tasklist_id = list_id
        AND "order" >= new_order;
    ELSIF new_order > old_order THEN
    -- task is assigned a higher order
        UPDATE task SET "order" = "order" - 1
        WHERE tasklist_id = list_id
        AND "order" > old_order AND "order" <= new_order;
    ELSE
    -- task is assigned a lower order
        UPDATE task SET "order" = "order" + 1
        WHERE tasklist_id = list_id
        AND "order" >= new_order AND "order" < old_order;
    END IF;

    -- return new order number when inserting without specifying the order
    RETURN new_order;
END;
$$ LANGUAGE plpgsql;

This function will update the orders on the task table and return the new order number in the case of a new task being inserted.

Now we just need to setup three triggers, before insert a task, before update a task order and after delete a task:

Before Insert:

CREATE FUNCTION task_insert_order() RETURNS TRIGGER AS $$
BEGIN
    NEW."order" := task_shift_order(NEW.tasklist_id, NEW."order", NULL);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER task_insert_order BEFORE INSERT ON task
    FOR EACH ROW
    EXECUTE PROCEDURE task_insert_order();

Before Update:

CREATE FUNCTION task_update_order() RETURNS TRIGGER AS $$
BEGIN
    NEW."order" := task_shift_order(NEW.tasklist_id, NEW."order", OLD."order");
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER task_update_order BEFORE UPDATE ON task
    FOR EACH ROW
    WHEN (pg_trigger_depth() = 0 -- prevent trigger recursion
        AND NEW."order" <> OLD."order") -- only execute when order changes
    EXECUTE PROCEDURE task_update_order();

We needed to set the condition WHEN (pg_trigger_depth() = 0 AND NEW."order" <> OLD."order") to avoid recursion, since the trigger updates the task table itself.

For the after delete trigger, we do not need to call task_shift_order() since the logic is simpler:

CREATE FUNCTION task_delete_order() RETURNS TRIGGER AS $$
BEGIN
    UPDATE task SET "order" = "order" - 1
        WHERE tasklist_id = OLD.tasklist_id
        AND "order" > OLD."order";
    RETURN OLD;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER task_delete_order AFTER DELETE ON task
    FOR EACH ROW
    EXECUTE PROCEDURE task_delete_order();

You may have noticed that I did not use a unique constraint on tasklist_id and order columns. This is because I was not able to update the orders without causing a conflict. If you know a way to do it, please tell it in the comments.

Posted on by:

Discussion

pic
Editor guide