3

Using PostgreSQL 9.2.4, I have a table users with a 1:many relation to the table user_roles. The users table stores both employees and other kinds of users.

                                       Table "public.users"
    Column       |            Type   |                      Modifiers
-----------------+-------------------+-----------------------------------------------------
 uid             | integer           | not null default nextval('users_uid_seq'::regclass)
 employee_number | character varying |
 name            | character varying |

Indexes:
    "users_pkey" PRIMARY KEY, btree (uid)
Referenced by:
    TABLE "user_roles" CONSTRAINT "user_roles_uid_fkey" FOREIGN KEY (uid) REFERENCES users(uid)

                                      Table "public.user_roles"
    Column |            Type   |                            Modifiers
-----------+-------------------+------------------------------------------------------------------
 id        | integer           | not null default nextval('user_roles_id_seq'::regclass)
 uid       | integer           |
 role      | character varying | not null
Indexes:
    "user_roles_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "user_roles_uid_fkey" FOREIGN KEY (uid) REFERENCES users(uid)

I want to ensure that the column users.employee_number cannot be NULL if there is a related row where user_roles.role_name contains an employee role name. That is, I want the database to enforce the constraint that for some roles, users.employee_number must have a value, but not for others.

How can I accomplish this, preferably without user-defined functions or triggers? I found (blog post, SO Answer) that SQL Server supports indexed views, which sounds like it would serve my purpose. However, I assume that materialized views will not work in my case, since they are not dynamically updated.

Community
  • 1
  • 1
Dag Høidahl
  • 7,873
  • 8
  • 53
  • 66
  • Can you provide more detail on what the columns are? Perhaps there is another solution to your problem. – Gordon Linoff Apr 16 '15 at 11:39
  • @Gordon Sure, I updated the question to clarify the use case somewhat. – Dag Høidahl Apr 16 '15 at 11:46
  • It would help if you provided table definitions (`\d tbl` in psql) and, as *always*, your version of Postgres. – Erwin Brandstetter Apr 16 '15 at 23:58
  • 1
    @ErwinBrandstetter I updated the question with the requested info for posterity. I also updated the table names to avoid the distracting naming problems, even though this might make your answer look odd. I do appreciate how you keep pointing out these kinds of issues. (Although in my actual DB the names were fine, and I made a mistake when extracting the relevant parts for this question.) – Dag Høidahl Apr 17 '15 at 12:31

3 Answers3

8

Clarification

The formulation of this requirement leaves room for interpretation:
"where user_roles.role_name contains an employee role name."

My interpretation:
"with an entry in user_roles that has role_name = 'employee'."

The problem

FOREIGN KEY and CHECK constraints are proven, air-tight tools to enforce relational integrity. Triggers are powerful, versatile features but more sophisticated, less strict, and with more room for design errors and corner cases.

A FK constraint seems impossible for your case at first: it requires a PRIMARY KEY or UNIQUE constraint to reference - neither allows NULL values. There are no partial FK constraints. The only escape from strict referential integrity are null values in referencing columns - with default MATCH SIMPLE behavior of FK constraints. The manual:

MATCH SIMPLE allows any of the foreign key columns to be null; if any of them are null, the row is not required to have a match in the referenced table.

See:

The workaround is to introduce a boolean flag is_employee to mark employees on both sides, defined NOT NULL in users, but allowed to be NULL in user_roles:

Solution

This enforces your requirements exactly, while keeping noise and overhead to a minimum:

CREATE TABLE users (
  users_id    serial PRIMARY KEY
, employee_nr int
, is_employee bool NOT NULL DEFAULT false
, CONSTRAINT role_employee CHECK (employee_nr IS NOT NULL = is_employee)  
, UNIQUE (is_employee, users_id)  -- required for FK (otherwise redundant)
);

CREATE TABLE user_roles (
  user_roles_id serial PRIMARY KEY
, users_id      int NOT NULL REFERENCES users
, role_name     text NOT NULL
, is_employee   bool CHECK(is_employee)
, CONSTRAINT role_employee CHECK (role_name <> 'employee' OR is_employee IS TRUE)
, CONSTRAINT role_employee_requires_employee_nr_fk
  FOREIGN KEY (is_employee, users_id) REFERENCES users(is_employee, users_id)
);

That's all.

Optional triggers for convenience

The following triggers are optional, but recommended for convenience to set the added tags is_employee automatically and you don't have to do anything extra:

-- users
CREATE OR REPLACE FUNCTION trg_users_insup_bef()
  RETURNS trigger
  LANGUAGE plpgsql AS
$func$
BEGIN
   NEW.is_employee := (NEW.employee_nr IS NOT NULL);
   RETURN NEW;
END
$func$;

CREATE TRIGGER insup_bef
BEFORE INSERT OR UPDATE OF employee_nr ON users
FOR EACH ROW
EXECUTE FUNCTION trg_users_insup_bef();


-- user_roles
CREATE OR REPLACE FUNCTION trg_user_roles_insup_bef()
  RETURNS trigger
  LANGUAGE plpgsql AS
$func$
BEGIN
   NEW.is_employee = true;
   RETURN NEW;
END
$func$;

CREATE TRIGGER insup_bef
BEFORE INSERT OR UPDATE OF role_name ON user_roles
FOR EACH ROW
WHEN (NEW.role_name = 'employee')
EXECUTE FUNCTION trg_user_roles_insup_bef();

No-nonsense, optimized and only called when needed.
(Before Postgres 11, use the (misleading) key word PROCEDURE. instead,)

fiddle
Old sqlfiddle. (Works with ancient Postgres 9.1, too.)

Major points

Now, if we want to set user_roles.role_name = 'employee', then there has to be a matching user.employee_nr first.

You can still add an employee_nr to any user, and you can (then) still tag any user_roles with is_employee, regardless of the actual role_name. Easy to disallow if you need to, but this implementation does not introduce any more restrictions than required.

users.is_employee can only be true or false and is forced to reflect the existence of an employee_nr by the CHECK constraint. The trigger keeps the column in sync automatically. You could allow false additionally for other purposes with only minor updates to the design.

The rules for user_roles.is_employee are slightly different: it must be true if role_name = 'employee'. Enforced by a CHECK constraint and set automatically by the trigger again. But it's allowed to change role_name to something else and still keep is_employee. Nobody said a user with an employee_nr is required to have an according entry in user_roles, just the other way round! Again, easy to enforce additionally if needed.

If there are other triggers that could interfere, consider:

But we need not worry that rules might be violated by the triggers. The requested rules are enforced with CHECK and FK constraints, which allow no exceptions.

Aside: I put the column is_employee first in the constraint UNIQUE (is_employee, users_id) for a reason. users_id is already covered in the PK, so it can take second place here:

Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228
  • 1
    . . I interpret "to enforce the constraint that for some roles" as being broader than just a single role called "employee". My interpretation was that there is a flag that says that an employee number is needed, not a particular role known in advance. – Gordon Linoff Apr 17 '15 at 02:31
  • 1
    @GordonLinoff: That may be. Either way, the flag you mention would be the same as `user_role.is_employee` in my design, which would make it a simpler subset of this solution. – Erwin Brandstetter Apr 17 '15 at 02:48
  • Regarding the interpretation of "some roles", Gordon Linoff is correct. As pointed out, this actually makes the solution simpler. – Dag Høidahl Apr 17 '15 at 12:05
  • 1
    @ErwinBrandstetter This solves my problem. Thanks! Took me a little time and reading to understand the `MATCH SIMPLE` behaviour. This led me to think that I may specify roles that are "unemployable" by setting `user_role.is_employee = false`. Which works, if I remove the `CHECK(is_employee)` from `user_role.is_employee`. Why did you put that check there in the first place? – Dag Høidahl Apr 17 '15 at 12:06
  • @DagHøidahl: I put it in, because there was no request for "unemployable user_roles". As I mentioned, the design can easily be extended - just like you did. – Erwin Brandstetter Apr 17 '15 at 15:26
  • @ErwinBrandstetter There probably won't be any need for "unemployable users" either, so that's an impressive attention to detail. – Dag Høidahl Apr 17 '15 at 15:31
  • 1
    @ErwinBrandstetter . . . As usual, you give a very thorough and well-explained answer. – Gordon Linoff Apr 17 '15 at 23:43
1

First, you can solve this using a trigger.

But, I think you can solve this using constraints, with just a little weirdness:

create table UserRoles (
    UserRoleId int not null primary key,
    . . .
    NeedsEmployeeNumber boolean not null,
    . . .
);

create table Users (
    . . .
    UserRoleId int,
    NeedsEmployeeNumber boolean,
    EmployeeNumber,
    foreign key (UserRoleId, NeedsEmployeeNumber) references UserRoles(UserRoleId, NeedsEmployeeNumber),
    check ((NeedsEmployeeNumber and EmployeeNumber is not null) or
           (not NeedsEmployeeNumber and EmployeeNumber is null)
          )
);

This should work, but it is an awkward solution:

  • When you add a role to an employee, you need to add the flag along with the role.
  • If a role is updated to change the flag, then this needs to be propagated to existing records -- and the propagation cannot be automatic because you also need to potentially set EmployeeNumber.
Gordon Linoff
  • 1,242,037
  • 58
  • 646
  • 786
0

New Answer:

This( SQL Sub queries in check constraint ) seems to answer your question, and the language is still in the 9.4 documentation( http://www.postgresql.org/docs/9.4/interactive/sql-createtable.html ).

Old Answer:

SELECT 
  User.*
  , UserRole1.*
FROM
 User
  LEFT JOIN UserRole UserRole1 
    ON User.id = UserRole1.UserId 
     AND (
      (
       User.employee_number IS NOT NULL AND UserRole1.role_name IN (enumerate employee role names here)
      ) 
     OR 
      (User.employee_number IS NULL)
     )

The above query selects all fields from User and all fields from UserRole(aliased as UserRole1). I assumed that the key field between the two fields is known as User.id and UserRole1.UserId, please change these to whatever the real values are.

In the JOIN part of the query there is an OR that on the left side requires an employee number not be NULL in the user table and that UserRole1.role_name be in a list that you must supply to the IN () operator.

The right part of the JOIN is the opposite, it requires that User.employee_number be NULL(this should be your non-employee set).

If you require a more exact solution then please provide more details on your table structures and what roles must be selected for employees.

Community
  • 1
  • 1
bf2020
  • 742
  • 4
  • 7