Postgresql unique constraint for colums of multiple related tables - sql

I have the following tables:
server (server_id as PK)
server_id | server_name
----------+---------------
3251623 | ServerOfDoom
5578921 | BestServerEU
server_groups
Each server can have multiple server groups, every group name within a server must be unique.
id | server_id | group_name
----+-----------+---------------
1 | 3251623 | mods
2 | 5578921 | admins
3 | 5578921 | anothergroup
group_tags
Each group can have multiple tags, a tag can be connected to multiple groups. Each tag name must be unique.
id | tag_name
----+--------------
1 | mods
2 | admins
3 | ag
group_tag_relations
group_id | tag_id
----------+--------
2 | 1
3 | 3
1 | 2
Problem:
I need group tag names and server group names to be unique within one server. So a tag can be connected to multiple groups, but only to one group per server. Or the tag "foo" cannot be connected to the group "bar" of server z because server z already has a group "foo". The same goes for the other way around, so if the group "foo" had the tag "hey" you shouldn't be able to add a group "hey" to the same server.
Basically names should not appear multiple times on a server, no matter if tag or group name.
How would I implement such a Constraint?
EDIT
So basically I'm trying to convert this JSON format into SQL tables:
{
"5578921": {
"Server Name": "Server1",
...
"Groups": {
"Game1": {
"Tags": [
"g1",
"gameone"
], ...
},
"Game2": {
"Tags": [
"g2",
"gametwo"
], ...
}
}
},
"3251623": ...
}
The id's 5578921 and 3251623 should just represent Discord server id's, so every id is for one server my bot is on. The Json file is all the information of my bot for each server, but it's not as reliable and scalable as a database, so I wanted to convert it.
So the upper tables are what I came up with:
A One-To-Many relation between server and server_groups and a Many-To-Many relation between server_groups and group_tags (so instead of storing duplicate tags I can just assign them to different groups).
I just want to make sure there are no duplicate names on one server and asking how to do so for my current tables.

As mentioned before, a stored procedure or function with triggers is probably the way to go.
The code could look something like this:
CREATE FUNCTION public."CHECK_TAG_AND_GROUP_NAME_UNIQUE_PER_SERVER"()
RETURNS trigger
LANGUAGE 'plpgsql'
NOT LEAKPROOF
AS $BODY$
DECLARE
countServerGroupsWithNamePerServer integer;
countGroupTagsWithNamePerServer integer;
BEGIN
-- Count occurrences of name in server_groups table
SELECT COUNT(*)
FROM server_groups
INTO countServerGroupsWithNamePerServer
WHERE "name" = NEW.name
AND "server_id" = NEW.server_id;
-- Check if one exists. If it does, throw error
IF countServerGroupsWithNamePerServer > 0 THEN
RAISE 'Name already exists as a group server name %', NEW.name;
END IF;
-- Count occurrences of name in group_tags table
SELECT COUNT(*)
FROM group_tags
INTO countGroupTagsWithNamePerServer
WHERE "name" = NEW.name
AND "server_id" = NEW.server_id;
-- Check if one exists. If it does, throw error
IF countGroupTagsWithNamePerServer > 0 THEN
RAISE 'Name already exists as a group_tag name %', NEW.name;
END IF;
-- If no error is thrown, insert the new record
RETURN NEW;
END;
$BODY$;
and then you attach the function as a BEFORE INSERT trigger to each of the two tables group_tags and server_groups:
CREATE TRIGGER "BEFORE_INSERT_CHECK_TAG_NAME_UNIQUE_PER_SERVER"
BEFORE INSERT
ON public.group_tags
FOR EACH ROW
EXECUTE PROCEDURE public."CHECK_TAG_AND_GROUP_NAME_UNIQUE_PER_SERVER"();
CREATE TRIGGER "BEFORE_INSERT_CHECK_TAG_NAME_UNIQUE_PER_SERVER"
BEFORE INSERT
ON public.server_groups
FOR EACH ROW
EXECUTE PROCEDURE public."CHECK_TAG_AND_GROUP_NAME_UNIQUE_PER_SERVER"();
Please notice for this example I also added a foreign key column server_id to the group_tags table. Otherwise we are not sure to which server the tag belongs. This is just a rough guide though, please feel free to change it up as much as you want.

After hours of suffering I finally got what I wanted:
Get all Tags of a server
CREATE OR REPLACE FUNCTION get_server_tags(serverid BIGINT)
RETURNS TABLE(group_name VARCHAR(100), tag_name VARCHAR(100), group_id BIGINT, tag_id BIGINT)
AS
$$
SELECT group_name, tag_name, group_id, tag_id FROM group_tag_relations
JOIN server_groups
ON server_groups.server_id = serverid
AND server_groups.id = group_tag_relations.group_id
JOIN group_tags
ON group_tags.id = group_tag_relations.tag_id
$$
language sql
stable;
Get all Groups of a server
CREATE OR REPLACE FUNCTION get_server_group(serverid BIGINT, groupname VARCHAR(100))
RETURNS TABLE(group_name VARCHAR(100), group_id BIGINT)
AS
$$
SELECT group_name, id
FROM server_groups
WHERE server_id = serverid
AND lower(group_name) = lower(groupname);
$$
language sql
stable;
Get a Group by Name (Calling both Functions above)
CREATE OR REPLACE FUNCTION get_group_by_name(serverid BIGINT, groupname VARCHAR(100))
RETURNS TABLE(group_name VARCHAR(100), group_id BIGINT)
AS
$$
BEGIN
RETURN QUERY SELECT get_server_group.group_name, get_server_group.group_id
FROM get_server_group(serverid, groupname);
IF NOT found THEN
RETURN QUERY SELECT get_server_tags.group_name, get_server_tags.group_id
FROM get_server_tags(serverid)
WHERE lower(tag_name) = lower(groupname);
END IF;
END;
$$
language plpgsql
stable;
Update Trigger for server_groups table, checking wether the name is already taken on a server
CREATE OR REPLACE FUNCTION group_name_update()
RETURNS TRIGGER
AS
$$
BEGIN
PERFORM get_group_by_name(OLD.server_id, NEW.group_name);
IF lower(OLD.group_name) = lower(NEW.group_name) THEN
RETURN NEW;
ELSIF found THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$
language plpgsql
volatile;
CREATE TRIGGER group_name_update_trigger
BEFORE UPDATE ON server_groups
FOR EACH ROW EXECUTE PROCEDURE group_name_update();
Insert Trigger for server_groups table, checking wether the name is already taken on a server
CREATE OR REPLACE FUNCTION group_name_insert()
RETURNS TRIGGER
AS
$$
BEGIN
PERFORM get_group_by_name(NEW.server_id, NEW.group_name);
IF found THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$
language plpgsql
volatile;
CREATE TRIGGER group_name_insert_trigger
BEFORE INSERT ON server_groups
FOR EACH ROW EXECUTE PROCEDURE group_name_insert();
Insert Trigger for group_tag_relation table, checking wether the tag is already taken on a server (as the tags wont get updated there's no need for an update trigger)
CREATE OR REPLACE FUNCTION group_tag_relation_insert()
RETURNS TRIGGER
AS
$$
BEGIN
PERFORM get_group_by_name((SELECT server_id FROM server_groups WHERE id = NEW.group_id), (SELECT tag_name FROM group_tags WHERE id = tag_id));
IF found THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$
language plpgsql
volatile;
CREATE TRIGGER group_tag_relation_insert_trigger
BEFORE INSERT ON group_tag_relations
FOR EACH ROW EXECUTE PROCEDURE group_tag_relation_insert();

Related

Returning a column from an INSERTED record via an updateable view

I have a view I need to allow my users to update and insert on. Importantly, when they Insert, they need to be able to return the new value from the Inserted row, however right now they get NULL. It must be a view, as in the SELECT of the view, it needs to be able to return values that are the result of a join.
My underlying table definitions:
CREATE TABLE my_assets (
asset_id bigserial not null primary key,
asset_price NUMERIC(32,10) -- This will vary constantly via an independent process
);
CREATE TABLE my_transactions (
id bigserial not null primary key,
asset_id bigint not null REFERENCES my_assets(asset_id),
some_text varchar(100)
);
INSERT INTO my_assets(asset_price) SELECT 100 as asset_price;
My view that shows the result of the table:
CREATE VIEW my_transactions_view AS
SELECT tx.id, tx.asset_id, tx.some_text, a.asset_price
FROM my_transactions tx
JOIN my_assets a ON tx.asset_id = a.asset_id
My trigger that allows insertion into my_transactions_view:
CREATE OR REPLACE FUNCTION trigfx_insert_to_my_transactions_view()
RETURNS trigger AS
$BODY$
BEGIN
INSERT INTO my_transactions(asset_id, some_text)
SELECT NEW.asset_id, NEW.some_text;
RETURN NEW;
END
$BODY$
LANGUAGE 'plpgsql';
CREATE TRIGGER trig_my_transactions_view INSTEAD OF INSERT on my_transactions_view
FOR EACH ROW EXECUTE PROCEDURE trigfx_insert_to_my_transactions_view();
All good so far. However, the problem arises from trying to run the below SQL:
INSERT INTO my_transactions_view(asset_id, some_text)
SELECT 1 as asset_id, 'Hello World' as some_text
RETURNING id, asset_id, some_text;
The returned table returns NULL for ID, but I want to return the newly updated ID from the my_transactions table:
|---------------------|------------------|------------------|
| ID | asset_id | some_text |
|---------------------|------------------|------------------|
| null | 1 | Hello World |
|---------------------|------------------|------------------|
Running a subsequent SELECT * FROM my_transactions_view DOES produce the updated result:
|------------------|------------------|------------------|------------------|
| ID | asset_id | some_text | asset_price |
|------------------|------------------|------------------|------------------|
| 1 | 1 | Hello World | 100.0000000 |
|------------------|------------------|------------------|------------------|
but I need it produced during the RETURNING of the INSERT statement.
Thank you!!!
You can populate the new record with the generated ID:
CREATE OR REPLACE FUNCTION trigfx_insert_to_my_transactions_view()
RETURNS trigger AS
$BODY$
BEGIN
INSERT INTO my_transactions(asset_id, some_text)
values (NEW.asset_id, NEW.some_text);
new.id := lastval(); --<< this gets the generated id from the transactions table
RETURN NEW;
END
$BODY$
LANGUAGE plpgsql;
Online example
Alternatively you could use currval(pg_get_serial_sequence('my_transactions','id')) instead of lastval()
Turns out we can avoid the extra function call via SELECT INTO from a CTE:
CREATE OR REPLACE FUNCTION trigfx_insert_to_my_transactions_view()
RETURNS trigger AS
$BODY$
BEGIN
WITH ins_q as (INSERT INTO my_transactions(asset_id, some_text)
values (NEW.asset_id, NEW.some_text)
RETURNING id, asset_id, some_text)
SELECT ins_q.id, ins_q.asset_id, ins_q.some_text
INTO NEW.id, NEW.asset_id, NEW.some_text
FROM ins_q;
RETURN NEW;
END
$BODY$
LANGUAGE plpgsql;
See online example here.
I was running into initialisation errors (lastval is not yet defined in this session) with the new.id := lastval(); approach.

Whats is the best workaround to inherit unique contraints in Postgres?

In Postgres I am trying to inherit an unique attribute from a parent to its children. The parent table is an abstract table, with no entries in it. The names of all children should be unique. Referring to the following little (stupid) example: There should be no apple with the same name as a banana (or of course other apple).
Minimalistic Postgres example for this scenario:
CREATE TABLE fruit(CONSTRAINT fruit_uniq_name UNIQUE (name))
CREATE TABLE banana(name text, length integer) INHERITS (fruit)
CREATE TABLE apple(name, diameter integer NOT NULL,) INHERITS (fruit)
After having read many posts on this problem. All of them came to the conclusion that this scenario is impossible to master only with Postgres' inheritance, I'd like to know if there is a best practice workaround, e.g. using triggers and functions, for this problem?
I would be very happy for every little snippet of code, that could help me out of this annoying trap.
I followed the advice of Laurenz Albe and I think I solved the problem by using triggers on the tables apple and banana and a trigger function tgf_name_exists() that tests the uniqueness.
This is the trigger function that tests for the uniqueness of the children names:
CREATE OR REPLACE FUNCTION tgf_name_exits()
RETURNS trigger
LANGUAGE 'plpgsql'
VOLATILE
COST 100
AS $BODY$
declare
count_apple integer;
count_banana integer;
name text;
schema text;
error_text text;
BEGIN
-- Variables
error_text = '';
schema = TG_TABLE_SCHEMA; -- actual schema
name = NEW.name; --- actual attribute name
-- Check
EXECUTE format('SELECT count(*) FROM %s.apple apl WHERE apl.name=%L', schema, name) INTO count_apple;
EXECUTE format('SELECT count(*) FROM %s.banana ban WHERE ban.name=%L', schema, name) INTO count_banana;
-- Info
RAISE NOTICE 'Schema: %', schema;
RAISE NOTICE 'Name: %', name;
RAISE NOTICE 'Count: %', count_apple;
RAISE NOTICE 'Count: %', count_banana;
IF count_apple > 0 OR count_banana > 0 THEN
-- Name ist already used
if count_apple > 0 then
error_text = error_text || "apple "
end if;
if count_banana > 0 then
error_text = error_text || "banana "
end if;
RAISE EXCEPTION 'Name % already existing in table %', name, error_text;
ELSE
-- Name is unused -> OK
RETURN NEW;
END IF;
END;
$BODY$;
These are the triggers for the tables apple and banana
CREATE TRIGGER tg_apple_name_instert_update
BEFORE INSERT OR UPDATE
ON apple
FOR EACH ROW
EXECUTE PROCEDURE tgf_name_exits();
CREATE TRIGGER tg_banana_name_uniq
BEFORE INSERT OR UPDATE
ON banana
FOR EACH ROW
EXECUTE PROCEDURE tgf_name_exits();
It would be very kind if someone could check this. From here it looks like it works.
maybe i am thinking too simplistically about this, but it seems like this functionality can be recreated like this :
CREATE FUNCTION fruit_check ( _name_ text )
RETURNS
boolean AS
$$ SELECT _name_ NOT IN ( SELECT name FROM fruit )
$$
STABLE LANGUAGE SQL ;
ALTER TABLE fruit ADD CHECK ( fruit_check ( name ) ) ;
CREATE TABLE banana ( LIKE fruit INCLUDING ALL , length int ) INHERITS ( fruit ) ;
CREATE TABLE apple ( LIKE fruit INCLUDING ALL , diameter int NOT NULL ) INHERITS ( fruit ) ;
-- now test ...
INSERT INTO banana VALUES ( 'ripe' , 21 ) ; -- INSERT 0 1
INSERT INTO apple VALUES ( 'rosie' , 21 ) ; -- INSERT 0 1
INSERT INTO banana VALUES ( 'rosie' , 21 ) ;
--
-- ERROR: new row for relation "banana" violates check constraint "fruit_name_check"
INSERT INTO apple VALUES ( 'ripe' , 21 ) ;
--
-- ERROR: new row for relation "apple" violates check constraint "fruit_name_check"
UPDATE apple set name = 'ripe' where name = 'rosie' ;
--
-- ERROR: new row for relation "apple" violates check constraint "fruit_name_check"

postgres: function with select and notify

So, I have a table which describes positions of some entities in space(for simplicity in one dimension, original problem is related )
CREATE TABLE positions(
id VARCHAR (32) UNIQUE NOT NULL,
position VARCHAR (50) NOT NULL
);
And I create a trigger which notifies on every update
CREATE TRIGGER position_trigger AFTER UPDATE ON positions
FOR EACH ROW
EXECUTE PROCEDURE notify_position();
and a function:
CREATE FUNCTION notify_position() RETURNS trigger AS $$
DECLARE
BEGIN
PERFORM pg_notify(
'positions',
TG_TABLE_NAME || ',id,' || NEW.id || ',position,' || NEW.position
);
RETURN new;
END;
$$ LANGUAGE plpgsql;
how can I change the function to notify only when there are few entities in the same position.
e.g. consider a table after update
id |positions
-------------
id1 |10
id2 |10
id3 |11
I need to call notify with a string 'id1,id2'
Probably, I need to select somehow all the entities which have the same position as updated one and create a list of them(a string which contains comma-separated ids). How can I do that?
From what I understand of your question, you want the following to happen when an entity's position is updated:
If no entities are currently in the new position, do nothing.
Otherwise call notify with a comma-separated list of entity IDs that are currently in the new position.
You can achieve this in your trigger function by using the string_agg aggregation function to construct a list of IDs of matching entities, then checking its length before calling pg_notify:
CREATE FUNCTION notify_position() RETURNS trigger AS $$
DECLARE
colocated_ids TEXT;
BEGIN
-- Construct list of IDs of co-located entities.
colocated_ids := (
SELECT string_agg(id, ',')
FROM positions
WHERE position = NEW.position AND id != NEW.id
);
-- Notify listeners if some co-located entities were found.
IF length(colocated_ids) > 0 THEN
PERFORM pg_notify('positions', colocated_ids);
END IF;
RETURN new;
END;
$$ LANGUAGE plpgsql;
Note that without the AND id != NEW.id check, the ID of the updated entity will also appear in the list. You could avoid this by making this trigger fire BEFORE UPDATE instead of AFTER UPDATE.

Writing an SQL trigger to compare old and new values

I am trying to write a SQL trigger that compares the old and new values. If the two values are different then I need to display an error saying that you can't update the names. The exact definition of my trigger is
write a trigger function named disallow_team_name_update that compares
the OLD and NEW records team fields. If they are different raise an
exception that states that changing the team name is not allowed.
Then, attach this trigger to the table with the name tr disallow team
name update and specify that it fires before any potential update of
the team field in the table.
The table that I am using for this problem is:
Table "table.group_standings"
Column | Type | Modifiers
--------+-----------------------+-----------
team | character varying(25) | not null
wins | smallint | not null
losses | smallint | not null
draws | smallint | not null
points | smallint| not null
Indexes:
"group_standings_pkey" PRIMARY KEY, btree (team)
Check constraints:
"group_standings_draws_check" CHECK (draws >= 0)
"group_standings_losses_check" CHECK (losses >= 0)
"group_standings_points_check" CHECK (points >= 0)
"group_standings_wins_check" CHECK (wins >= 0)
Here is my code:
CREATE OR REPLACE FUNCTION disallow_team_name_update() RETURNS trigger AS $$
BEGIN
if(NEW.team <> OLD.team)
/*tell the user to not change team names*/
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_disallow_team_name_update BEFORE INSERT OR UPDATE OF team ON group_standings
FOR EACH ROW EXECUTE PROCEDURE disallow_team_name_update();
PostgreSQL can use raise exception to, um, raise exceptions.
CREATE OR REPLACE FUNCTION disallow_team_name_update()
RETURNS trigger AS
$$
BEGIN
if(NEW.team <> OLD.team) then
raise exception 'Invalid update.'
using hint = 'Changes to team name are not allowed.';
end if;
END
$$
LANGUAGE plpgsql;
You surely don't want to disallow changes to the team name on insert. (PostgreSQL won't let you do it anyway.)
CREATE TRIGGER tr_disallow_team_name_update
BEFORE UPDATE OF team ON group_standings
FOR EACH ROW EXECUTE PROCEDURE disallow_team_name_update();

PLPGSQL Cascading Triggers?

I am trying to create a trigger, so that when ever I add a new record it adds another record in the same table. The session field will only take values between 1 and 4. So when I add a 1 in session I want it to add another record but with session 3 blocked. But the problem is that it leads to cascading triggers and it inserts itself again and again because the trigger is triggered when inserted.
I have for example a simple table:
CREATE TABLE example
(
id SERIAL PRIMARY KEY
,name VARCHAR(100) NOT NULL
,session INTEGER
,status VARCHAR(100)
);
My trigger function is:
CREATE OR REPLACE FUNCTION add_block() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO example VALUES (NEW.id + 1, NEW.name, NEW.session+2, 'blocked');
RETURN NULL;
END;
$$ LANGUAGE 'plpgsql';
Trigger is:
CREATE TRIGGER add_block
AFTER INSERT OR UPDATE
ON example
FOR EACH ROW
EXECUTE PROCEDURE add_block();
I get error:
SQL statement "INSERT INTO example VALUES ( $1 +1, $2 , $3 + 2, $4)"
PL/pgSQL function "add_block" line 37 at SQL statement
This error repeats itself so many times that I can't see the top.
How would I solve this?
EDIT:
CREATE TABLE block_rules
(
id SERIAL PRIMARY KEY
,session INTEGER
,block_session INTEGER
);
This table holds the block rules. So if a new record is inserted into the EXAMPLE table with session 1 then it blocks session 3 accordingly by inserting a new record with blocked status in the same (EXAMPLE) table above (not block_rules). Same for session 2 but it blocks session 4.
The block_rules table holds the rules (or pattern) to block a session by. It holds
id | session | block_session
------------------------------
1 | 1 | 3
2 | 2 | 4
3 | 3 | 2
How would I put that in the WHEN statement of the trigger going with Erwin Branstetter's answer below?
Thanks
New answer to edited question
This trigger function adds blocked sessions according to the information in table block_rules.
I assume that the tables are linked by id - information is missing in the question.
I now assume that the block rules are general rules for all sessions alike and link by session. The trigger is only called for non-blocked sessions and inserts a matching blocked session.
Trigger function:
CREATE OR REPLACE FUNCTION add_block()
RETURNS TRIGGER AS
$BODY$
BEGIN
INSERT INTO example (name, session, status)
VALUES (NEW.name
,(SELECT block_session
FROM block_rules
WHERE session = NEW.session)
,'blocked');
RETURN NULL;
END;
$BODY$ LANGUAGE plpgsql;
Trigger:
CREATE TRIGGER add_block
AFTER INSERT -- OR UPDATE
ON example
FOR EACH ROW
WHEN (NEW.status IS DISTINCT FROM 'blocked')
EXECUTE PROCEDURE add_block();
Answer to original question
There is still room for improvement. Consider this setup:
CREATE OR REPLACE FUNCTION add_block()
RETURNS TRIGGER AS
$BODY$
BEGIN
INSERT INTO example (name, session, status)
VALUES (NEW.name, NEW.session + 2, 'blocked');
RETURN NULL;
END;
$BODY$ LANGUAGE plpgsql;
CREATE TRIGGER add_block
AFTER INSERT -- OR UPDATE
ON example
FOR EACH ROW
WHEN (NEW.session < 3)
-- WHEN (status IS DISTINCT FROM 'blocked') -- alternative guess at filter
EXECUTE PROCEDURE add_block();
Major points:
For PostgreSQL 9.0 or later you can use a WHEN condition in the trigger definition. This would be most efficient. For older versions you use the same condition inside the trigger function.
There is no need to add a column, if you can define criteria to discern auto-inserted rows. You did not tell, so I assume that only auto-inserted rows have session > 2 in my example. I added an alternative WHEN condition for status = 'blocked' as comment.
You should always provide a column list for INSERTs. If you don't, later changes to the table may have unexpected side effects!
Do not insert NEW.id + 1 in the trigger manually. This won't increment the sequence and the next INSERT will fail with a duplicate key violation.
id is a serial column, so don't do anything. The default nextval() from the sequence is inserted automatically.
Your description only mentions INSERT, yet you have a trigger AFTER INSERT OR UPDATE. I cut out the UPDATE part.
The keyword plpgsql doesn't have to be quoted.
OK so can't you just add another column, something like this:
ALTER TABLE example ADD COLUMN trig INTEGER DEFAULT 0;
CREATE OR REPLACE FUNCTION add_block() RETURNS TRIGGER AS $$
BEGIN
IF NEW.trig = 0 THEN
INSERT INTO example VALUES (NEXTVAL('example_id_seq'::regclass), NEW.name, NEW.session+2, 'blocked', 1);
END IF;
RETURN NULL;
END;
$$ LANGUAGE 'plpgsql';
it's not great, but it works :-)
CREATE OR REPLACE FUNCTION add_block() RETURNS TRIGGER AS $$
BEGIN
SET SESSION session_replication_role = replica;
INSERT INTO example VALUES (NEXTVAL('example_id_seq'::regclass), NEW.name, NEW.session+2, 'blocked');
SET SESSION session_replication_role = origin;
RETURN NULL;
END;
$$ LANGUAGE 'plpgsql';