Task
How convenient it is to make selections from trees such as Nested Sets, and how not convenient to manage it. It’s convenient to manage trees like id-> parent_id, but it’s not convenient and expensive to use recursions for selections. It is clear that when using modules for managing trees, part of the problem is removed, but the process of working with the database is not completely transparent i.e. to change the data we use some methods, to change the location of the node in the tree - others, plus transactions would not hurt. This discrepancy can be solved in two ways:
- Use stored procedures to work with the table, in which combine both update methods (insert, delete);
- Use triggers to exclude any non-standard working methods;
The first method is inconvenient in that when you change the structure of the table, we will also need to change the procedure, as well as be as careful as possible when working with the table so that all changes to the data go through our procedures, and not by direct requests. The second method makes the table somewhat more difficult by introducing additional Boolean fields, and you also have to do some “feint with your ears”, although it allows you to achieve maximum transparency of the work. The first method is to the furnace, especially somewhere on the Internet there is already a similar solution. The database is PostgreSQL, as relevant to me at the moment, add-ons for MySQL will write later.
Table
So, what triggers will we need:
- Before inserting a record, to create a gap in the tree and keys for the created node;
- Before updating - to rebuild the tree and generate keys for the updated node;
- After removal - removal of the gap in the tree remaining after the removal of the node;
Rake:
- For the duration of the triggers, you need to lock the table, or a separate tree, if you carried several trees in the same table;
- In PostgreSQL and MySQL, you cannot disable recursion in triggers, like this;
The second point is more detailed: In a trigger before updating, records from the same table can be updated, which will entail a repeated call of the trigger, and so on, as well for a trigger called after deletion. In order to understand whether the request from the trigger is called from us or not, we introduce two additional Boolean fields, which we will pass in the request as a parameter (flag) for the trigger, and not as data. Why two? I’ll tell you later. We will form the structure of the table immediately, taking into account the fact that several trees will be stored in it. I will explain why. It is ridiculous to tears to listen to stupid developers who prove with foam at the mouth that they say, ah-ah-ah, with a large number of nodes, updating nodes can affect the whole tree, and this is so hard for the database. Yes exactly. I do not argue.
- I do not use a common root node;
- I divide the trees into nodes of the zero level;
Example: There is some forum. The forum section has 1'000 posts, each post has 1'000 comments. Total comments - 1'000'000. So, the forum section is NOT the root node of comments, just like posts are NOT nodes of the same tree, they are only tree separators. As a result, I have 1'000 separate trees with 1'000 comments each. Updates take place only within a maximum of 1,000 entries. In some cases, if there is a lot of this, tree separators are first-level comments. As a result, rebuilding a tree is not such a load on the base. Learn the math part. Let's not talk about the sad, table structure: SQL code (1)
CREATE
ns_tree (
id SERIAL,
left_key INTEGER NOT NULL,
right_key INTEGER NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
tree INTEGER NOT NULL,
parent_id INTEGER NOT NULL DEFAULT 0,
_trigger_lock_update BOOLEAN NOT NULL DEFAULT FALSE,
_trigger_for_delete BOOLEAN NOT NULL DEFAULT FALSE,
field_1 ...,
...
PRIMARY KEY (id)
);
Actually -
_trigger_lock_update and
_trigger_for_delete , are our auxiliary fields. We will immediately make the function block the tree to change until the transaction is completed: SQL code (2)
CREATE OR REPLACE FUNCTION lock_ns_tree (integer)
RETURNS boolean AS
$ BODY $
DECLARE tree_id ALIAS FOR $ 1;
_id INTEGER;
BEGIN
SELECT id
INTO _id
FROM ns_tree
WHERE tree = tree_id FOR UPDATE;
RETURN TRUE;
END
$ BODY $
LANGUAGE 'plpgsql' VOLATILE
COST 100;
ALTER FUNCTION lock_ns_tree (integer) OWNER TO user;
Create Record
We have 3 options for adding a node to the tree:
- Adding subordination to a specific node, then we pass parent_id ;
- Adding to a specific point in the tree, then we pass left_key ;
- Adding to the end of the tree, then we do not need to transfer anything extra;
In the same sequence, we will determine the destination of the created node. SQL code (3)
CREATE OR REPLACE FUNCTION ns_tree_before_insert_func ()
RETURNS trigger AS
$ BODY $
Decare
_left_key INTEGER;
_level INTEGER;
_tmp_left_key INTEGER;
_tmp_right_key INTEGER;
_tmp_level INTEGER;
_tmp_id INTEGER;
_tmp_parent_id INTEGER;
BEGIN
PERFORM lock_ns_tree (NEW.tree);
- You can’t set these fields with pens:
NEW._trigger_for_delete: = FALSE;
NEW._trigger_lock_update: = FALSE;
_left_key: = 0;
_level: = 0;
- If we indicated the parent:
IF NEW.parent_id IS NOT NULL AND NEW.parent_id> 0 THEN
SELECT right_key, "level" + 1
INTO _left_key, _level
FROM ns_tree
WHERE id = NEW.parent_id AND
tree = NEW.tree;
END IF;
- If we specified the left key:
IF NEW.left_key IS NOT NULL AND
NEW.left_key> 0 AND
(_left_key IS NULL OR _left_key = 0) THEN
SELECT id, left_key, right_key, "level", parent_id
INTO _tmp_id, _tmp_left_key, _tmp_right_key, _tmp_level, _tmp_parent_id
FROM ns_tree
WHERE tree = NEW.tree AND (left_key = NEW.left_key OR right_key = NEW.left_key);
IF _tmp_left_key IS NOT NULL AND _tmp_left_key> 0 AND NEW.left_key = _tmp_left_key THEN
NEW.parent_id: = _tmp_parent_id;
_left_key: = NEW.left_key;
_level: = _tmp_level;
ELSIF _tmp_left_key IS NOT NULL AND _tmp_left_key> 0 AND NEW.left_key = _tmp_right_key THEN
NEW.parent_id: = _tmp_id;
_left_key: = NEW.left_key;
_level: = _tmp_level + 1;
END IF;
END IF;
- If the parent or left key is not specified, or we did not find anything:
IF _left_key IS NULL OR _left_key = 0 THEN
SELECT MAX (right_key) + 1
INTO _left_key
FROM ns_tree
WHERE tree = NEW.tree;
IF _left_key IS NULL OR _left_key = 0 THEN
_left_key: = 1;
END IF;
_level: = 0;
NEW.parent_id: = 0;
END IF;
- Set the received keys for the node:
NEW.left_key: = _left_key;
NEW.right_key: = _left_key + 1;
NEW. "Level": = _level;
- We form a break in the tree at the insertion point:
UPDATE ns_tree
SET left_key = left_key +
CASE WHEN left_key> = _left_key
THEN 2
ELSE 0
END,
right_key = right_key + 2,
_trigger_lock_update = TRUE
WHERE tree = NEW.tree AND right_key> = _left_key;
RETURN NEW;
END
$ BODY $
LANGUAGE 'plpgsql' VOLATILE
COST 100;
ALTER FUNCTION ns_tree_before_insert_func () OWNER TO user;
CREATE TRIGGER ns_tree_before_insert_tr
BEFORE INSERT
ON ns_tree
FOR EACH ROW
EXECUTE PROCEDURE ns_tree_before_insert_func ();
Now some clarifications:
- _trigger_lock_update and _trigger_for_delete are control fields, so we immediately reset them regardless of the user's wishes;
- Even if we specified parent_id , it’s not a fact that we have such a node and that it is in the corresponding tree. In the current case, if I cannot find a node in this tree, then parent_id is reset, and the node is inserted at the end of the tree. Alternatively, you can filter by tree, but just search for a node by id , then you will need to update the tree field of the created node. It all depends on the priority of the fields and the specific implementation;
- If we specified the left key, then at least we need to calculate the parent of the created node, we determine the parent according to the principle: if we found the node using the left key, then the parent will be the same as the found node, otherwise if it is right, then the parent will be the node we found, we also build the level. If the node is not found, then insert at the end of the tree, left_key is then reset;
- During the formation of the gap, the _trigger_lock_update field is additionally transferred - just so that the trigger for UPDATE knows that this update is connected exclusively with the structure of the tree, and no additional calculations and changes in the structure are required, since the final key values are already transmitted;
Edit Record
More precisely, this trigger will work exclusively with the structure of the tree, and not with mutable
data. The main parameters forcing it to take some action are
parent_id or
left_key (remembering, of course,
_trigger_lock_update as a control parameter for the trigger). The algorithm is simple: first, the coordinates of movement, then rebuild the tree. SQL code (4)
CREATE OR REPLACE FUNCTION ns_tree_before_update_func ()
RETURNS trigger AS
$ BODY $
Decare
_left_key INTEGER;
_level INTEGER;
_skew_tree INTEGER;
_skew_level INTEGER;
_skew_edit INTEGER;
_tmp_left_key INTEGER;
_tmp_right_key INTEGER;
_tmp_level INTEGER;
_tmp_id INTEGER;
_tmp_parent_id INTEGER;
BEGIN
PERFORM lock_ns_tree (OLD.tree);
- Is it worth it to do anything at all:
IF NEW._trigger_lock_update = TRUE THEN
NEW._trigger_lock_update: = FALSE;
IF NEW._trigger_for_delete = TRUE THEN
NEW = OLD;
NEW._trigger_for_delete = TRUE;
RETURN NEW;
END IF;
RETURN NEW;
END IF;
- We reset the values of the fields that the user cannot change:
NEW._trigger_for_delete: = FALSE;
NEW.tree: = OLD.tree;
NEW.right_key: = OLD.right_key;
NEW. "Level": = OLD. "Level";
IF NEW.parent_id IS NULL THEN NEW.parent_id: = 0; END IF;
- Check if there are changes related to the structure of the tree
IF NEW.parent_id = OLD.parent_id AND NEW.left_key = OLD.left_key
THEN
RETURN NEW;
END IF;
- We are rebuilding the tree, well, let's proceed:
_left_key: = 0;
_level: = 0;
_skew_tree: = OLD.right_key - OLD.left_key + 1;
- Determine where we are moving it:
- If parent_id is changed:
IF NEW.parent_id <> OLD.parent_id THEN
- If in subjection to another evil:
IF NEW.parent_id> 0 THEN
SELECT right_key, level + 1
INTO _left_key, _level
FROM ns_tree
WHERE id = NEW.parent_id AND tree = NEW.tree;
- Otherwise, we transfer to the root of the tree:
ELSE
SELECT MAX (right_key) + 1
INTO _left_key
FROM ns_tree
WHERE tree = NEW.tree;
_level: = 0;
END IF;
- If suddenly the parent is in the range of the moved node, check:
IF _left_key IS NOT NULL AND
_left_key> 0 AND
_left_key> OLD.left_key AND
_left_key <= OLD.right_key
THEN
NEW.parent_id: = OLD.parent_id;
NEW.left_key: = OLD.left_key;
RETURN NEW;
END IF;
END IF;
- If left_key is specified, and parent_id is not
IF _left_key IS NULL OR _left_key = 0 THEN
SELECT id, left_key, right_key, "level", parent_id
INTO _tmp_id, _tmp_left_key, _tmp_right_key, _tmp_level, _tmp_parent_id
FROM ns_tree
WHERE tree = NEW.tree AND (right_key = NEW.left_key OR right_key = NEW.left_key - 1)
LIMIT 1;
IF _tmp_left_key IS NOT NULL AND _tmp_left_key> 0 AND NEW.left_key - 1 = _tmp_right_key THEN
NEW.parent_id: = _tmp_parent_id;
_left_key: = NEW.left_key;
_level: = _tmp_level;
ELSIF _tmp_left_key IS NOT NULL AND _tmp_left_key> 0 AND NEW.left_key = _tmp_right_key THEN
NEW.parent_id: = _tmp_id;
_left_key: = NEW.left_key;
_level: = _tmp_level + 1;
ELSIF NEW.left_key = 1 THEN
NEW.parent_id: = 0;
_left_key: = NEW.left_key;
_level: = 0;
ELSE
NEW.parent_id: = OLD.parent_id;
NEW.left_key: = OLD.left_key;
RETURN NEW;
END IF;
END IF;
- Now we know where we are moving the tree
_skew_level: = _level - OLD. "level";
IF _left_key> OLD.left_key THEN
- Move up the tree
_skew_edit: = _left_key - OLD.left_key - _skew_tree;
UPDATE ns_tree
SET left_key = CASE WHEN right_key <= OLD.right_key
THEN left_key + _skew_edit
ELSE CASE WHEN left_key> OLD.right_key
THEN left_key - _skew_tree
ELSE left_key
End
END,
"level" = CASE WHEN right_key <= OLD.right_key
THEN "level" + _skew_level
ELSE "level"
END,
right_key = CASE WHEN right_key <= OLD.right_key
THEN right_key + _skew_edit
ELSE CASE WHEN right_key <_left_key
THEN right_key - _skew_tree
ELSE right_key
End
END,
_trigger_lock_update = TRUE
WHERE tree = OLD.tree AND
right_key> OLD.left_key AND
left_key <_left_key AND
id <> OLD.id;
_left_key: = _left_key - _skew_tree;
ELSE
- Moving down the tree:
_skew_edit: = _left_key - OLD.left_key;
UPDATE ns_tree
SET
right_key = CASE WHEN left_key> = OLD.left_key
THEN right_key + _skew_edit
ELSE CASE WHEN right_key <OLD.left_key
THEN right_key + _skew_tree
ELSE right_key
End
END,
"level" = CASE WHEN left_key> = OLD.left_key
THEN "level" + _skew_level
ELSE "level"
END,
left_key = CASE WHEN left_key> = OLD.left_key
THEN left_key + _skew_edit
ELSE CASE WHEN left_key> = _left_key
THEN left_key + _skew_tree
ELSE left_key
End
END,
_trigger_lock_update = TRUE
WHERE tree = OLD.tree AND
right_key> = _left_key AND
left_key <OLD.right_key AND
id <> OLD.id;
END IF;
- The tree was rebuilt, only our current node remained
NEW.left_key: = _left_key;
NEW. "Level": = _level;
NEW.right_key: = _left_key + _skew_tree - 1;
RETURN NEW;
END
$ BODY $
LANGUAGE 'plpgsql' VOLATILE
COST 100;
ALTER FUNCTION ns_tree_before_update_func () OWNER TO user;
CREATE TRIGGER ns_tree_before_update_tr
BEFORE UPDATE
ON ns_tree
FOR EACH ROW
EXECUTE PROCEDURE ns_tree_before_update_func ();
Explanations:
- Initially, in addition to the _trigger_lock_update parameter , we also check the _trigger_for_delete parameter . This is done because during deletion, we don’t pass parameters like changing a field, therefore we will trigger the deletion of records through the UPDATE of certain records. However, it will become more clear further;
- Again, in this case, parent_id is more priority than left_key , so we check it first;
- When checking left_key, we initially select either the node that will be before the moved node ( right_key = _left_key + 1 ), or the node to which we move the node ( right_key = _left_key ). At the same time, in some cases, the query result will return 2 nodes, both the future neighbor and the future parent, which does not affect the logic in any way; therefore, LIMIT 1 is set so that it’s not just not to select extra data, sorting is not important, since even if the result of the sample is 2 nodes, both of them will be correct, therefore it makes no difference to us which one will return to us. But I want to draw attention to the fact that if we indicate at the moved node left_key = 1, then it’s natural that we won’t have any upstream or parent nodes, for which we use the additional condition " ELSIF NEW.left_key = 1 ";
- When rebuilding the tree, an additional condition is " id <> OLD.id ", this is because we cannot change the record in the trigger, which we are changing now.
Delete record
Here with the removal of the most difficult. Firstly, because there are 2 principles for removing nodes:
- Removing a node with descendants;
- Removing a node without descendants, while the child nodes are shifted up the level in the tree;
Secondly, we cannot pass parameters in the deletion request to limit the recursive call of the trigger, however, the recursive call of the trigger is relevant only for the case of deleting a node with descendants. It will be unprofitable to make a universal trigger for both deletion principles, there will be too much logic. Two different solutions are better after all. In the first solution (deleting a node with descendants) we will have the following algorithm:
- Update the child nodes to set the field (parameter) _trigger_for_delete ;
- We delete child nodes;
- We remove the gap in the keys in the tree (rearrange the tree);
SQL code (5)
CREATE OR REPLACE FUNCTION ns_tree_after_delete_func ()
RETURNS trigger AS
$ BODY $
Decare
_skew_tree INTEGER;
BEGIN
PERFORM lock_ns_tree (OLD.tree);
- Check if the trigger is worth executing:
IF OLD._trigger_for_delete = TRUE THEN RETURN OLD; END IF;
- We mark on removal of child nodes:
UPDATE ns_tree
SET _trigger_for_delete = TRUE,
_trigger_lock_update = TRUE
WHERE
tree = OLD.tree AND
left_key> OLD.left_key AND
right_key <OLD.right_key;
- Delete the marked nodes:
DELETE FROM ns_tree
WHERE
tree = OLD.tree AND
left_key> OLD.left_key AND
right_key <OLD.right_key;
- We remove the gap in the keys:
_skew_tree: = OLD.right_key - OLD.left_key + 1;
UPDATE ns_tree
SET left_key = CASE WHEN left_key> OLD.left_key
THEN left_key - _skew_tree
ELSE left_key
END,
right_key = right_key - _skew_tree,
_trigger_lock_update = TRUE
WHERE right_key> OLD.right_key AND
tree = OLD.tree;
RETURN OLD;
END
$ BODY $
LANGUAGE 'plpgsql' VOLATILE
COST 100;
ALTER FUNCTION ns_tree_after_delete_func () OWNER TO user;
CREATE TRIGGER ns_tree_after_delete_tr
AFTER DELETE
ON ns_tree
FOR EACH ROW
EXECUTE PROCEDURE ns_tree_after_delete_func ();
In the second solution, we simply shift the child tree up one level and remove the key break. SQL code (6)
CREATE OR REPLACE FUNCTION ns_tree_after_delete_2_func ()
RETURNS trigger AS
$ BODY $
Decare
BEGIN
PERFORM lock_ns_tree (OLD.tree);
- We remove the gap in the keys and move the child nodes:
UPDATE ns_tree
SET left_key = CASE WHEN left_key <OLD.left_key
THEN left_key
ELSE CASE WHEN right_key <OLD.right_key
THEN left_key - 1
ELSE left_key - 2
End
END,
"level" = CASE WHEN right_key <OLD.right_key
THEN "level" - 1
ELSE "level"
END,
parent_id = CASE WHEN right_key <OLD.right_key AND "level" = OLD.level + 1
THEN OLD.parent_id
ELSE parent_id
END,
right_key = CASE WHEN right_key <OLD.right_key
THEN right_key - 1
ELSE right_key - 2
END,
_trigger_lock_update = TRUE
WHERE (right_key> OLD.right_key OR
(left_key> OLD.left_key AND right_key <OLD.right_key)) AND
tree = OLD.tree;
RETURN OLD;
END
$ BODY $
LANGUAGE 'plpgsql' VOLATILE
COST 100;
ALTER FUNCTION ns_tree_after_delete_2_func () OWNER TO user;
CREATE TRIGGER ns_tree_after_delete_2_tr
AFTER DELETE
ON ns_tree
FOR EACH ROW
EXECUTE PROCEDURE ns_tree_after_delete_2_func ();
Actually everything. It remains only to put down the indices (I am lazy to write SQL commands here, so I’ll just voice them):
- Composite is not unique to fields ( left_key, right_key, level, tree );
- Not unique on the field ( parent_id );
Enjoy ;-)