postgresqltriggersconstraints

Why is this Deferred Constraint Trigger Raising an Exception on the Not Final State of the Record


Why is my deferred constraint checking an intermediate state and not the final state of the record?

We use PostgreSQL 16.9 for our database and we use Python3 and Psycopg2 to manage our data pipeline.

We have a table in our database, member that contains the member data (primary key: member_id), along with its parent_id that is a foreign key reference to another member.

This table is populated through three means:

The source of the record is identified through the source_id field on the table.

There are two fields in the member table that are managed through our application. These fields are required for all application or external data drop source records.

I implemented this constraint using a constraint trigger rather than a check(function) due to the foreign key reference existing on the source field. I also set it to be deferrable initially deferred due to the stored procedure for the external data drops.

For the external data drops, we use a stored procedure to load the data from our staging table stage to our working table member. This stored procedure inserts new members or updates existing members and then sets the parent_id and then sets the child's inherited values using the parent_id.

Each record's parent references either a record that existed BEFORE the new drop, a record that was inserted DURING the new drop, or is null.

The stored procedure does not set the parent_id at the same time as the inherited values because if the parent_id record is a new record with a null parent_id then the inherited values should be set to the default values.

The expected behavior is that the exception is only thrown if the END state of the record violates the constraint due to both the trigger being defined as deferrable initially deferred and that set constraints all deferred; is the first line in the procedure.

I have added raise notice statements throughout the stored procedure that log the output of various select statements that get the values from the member table at various times in the procedure. The last raise has the final values as being not null which satisfies the constraint, but when the procedure ends, it is throwing an exception and the exception is outputting the values of a previous state.

I have added the CTID value to be logged during the procedure and returned in the exception from the trigger in order to confirm the assumption that it was not validating the final version of the record being inserted and the final CTID value does not match the CTID value returned in the exception.

-- create tables:
drop table if exists member;
drop table if exists first_inherited_field;
drop table if exists second_inherited_field;
drop table if exists source;
drop table if exists stage;

CREATE TABLE source
(
    source_id integer primary key generated always as identity ,
    name varchar UNIQUE not null
);

insert into source (name)
values ('APP'),
       ('PROC');

CREATE TABLE first_inherited_field
(
    first_inherited_field_id integer primary key generated always as identity,
    name varchar not null unique
);

insert into first_inherited_field (name)
values ('DEFAULT'),
       ('TEST');

CREATE TABLE second_inherited_field
(
    second_inherited_field_id integer primary key generated always as identity,
    name varchar not null unique
);

insert into second_inherited_field (name)
values ('DEFAULT'),
       ('TEST');
drop table if exists member;
CREATE TABLE member
(
    member_id integer primary key generated always as identity,
    name varchar NOT NULL,
    first_inheritable_field_id integer references first_inherited_field (first_inherited_field_id),
    second_inheritable_field_id integer references second_inherited_field (second_inherited_field_id),
    source_id integer NOT NULL references source (source_id),
    parent_member_id integer references member (member_id)
    -- <other fields>
);

CREATE UNIQUE INDEX family_name_source_unq ON member (name, source_id);

CREATE TABLE stage
(
    name varchar,
    parent_name varchar
    -- <other fields>
);

INSERT INTO stage (name, parent_name)
values ('TEST', 'PARENT'),
       ('PARENT', null);


-- function and trigger definition:
CREATE OR REPLACE FUNCTION required_fields() RETURNS TRIGGER AS
$$
DECLARE
    app_source_id integer = (select source_id from source where name = 'APP'); -- the application has specific restrictions that do not apply to the data pipeline data
    proc_source_id integer = (select source_id from source where name = 'PROC'); -- this is the source handling the stored proc data called by python
BEGIN
    IF new.source_id = app_source_id
    THEN
        RETURN NEW;
        --<do some checks based on app constraints>
    ELSEIF new.source_id = proc_source_id
    THEN
        IF (new.first_inheritable_field_id is not null
            AND new.second_inheritable_field_id is not null)
            AND tg_op ILIKE 'UPDATE'
        THEN
            RETURN NEW;
        ELSE
            RAISE EXCEPTION 'Required fields are null. Member_Id: %, MEMBER NAME: %, first_inheritable_trait_id: %, second_inheritable_trait_id: %, parent_member_id: %, CTID: %', new.member_id, new.name, new.first_inheritable_field_id, new.second_inheritable_field_id, new.parent_member_id, new.ctid;
        END IF;
    ELSE
        RETURN NEW;
    END IF;
END;
$$ LANGUAGE PLPGSQL;

CREATE CONSTRAINT TRIGGER check_required_fields
    AFTER INSERT OR UPDATE
    ON member
    DEFERRABLE INITIALLY DEFERRED
    FOR EACH ROW
EXECUTE FUNCTION required_fields();

-- stored proc:
CREATE OR REPLACE PROCEDURE load_proc_data() AS
$$
DECLARE
    proc_source_id integer = (select source_id from source where name = 'PROC');
    insert_count integer;
    default_first_trait integer = (select first_inherited_field_id from first_inherited_field where name = 'DEFAULT');
    default_second_trait integer = (select second_inherited_field_id from second_inherited_field where name = 'DEFAULT');
BEGIN
    SET CONSTRAINTS ALL DEFERRED;

    INSERT INTO member(name, source_id)
    SELECT name,
           proc_source_id
    FROM stage
    WHERE not exists (SELECT 1
                      FROM member
                      WHERE member.name = stage.name
                        AND member.source_id = proc_source_id);
    get diagnostics insert_count = row_count;

    with parents as (select stage.name,
                            stage.parent_name,
                            member.member_id as child_id,
                            member.name,
                            parent.member_id as parent_id,
                            parent.name as parent_name
                     from stage
                              left join member
                                        on member.name = stage.name
                              left join member parent
                                        on parent.name = stage.parent_name)
    UPDATE member
    SET parent_member_id = parents.parent_id
    from parents
    where parents.child_id = member.member_id
      and member.parent_member_id is distinct from parents.parent_id;

    if insert_count > 0 THEN
        update member
        set first_inheritable_field_id = default_first_trait,
            second_inheritable_field_id = default_second_trait
        where member.parent_member_id is null
        and member.source_id = proc_source_id;

        update member
        set first_inheritable_field_id = coalesce(parent.first_inheritable_field_id, default_first_trait),
            second_inheritable_field_id = coalesce(parent.second_inheritable_field_id, default_second_trait)
        from member parent
        where parent.member_id = member.parent_member_id
          and member.first_inheritable_field_id is null
          and member.second_inheritable_field_id is null
          and member.source_id = proc_source_id;
        raise notice '(LOCATION: In IF Statement) Member Name: TEST - first_inherited_field_id: %)', (select first_inheritable_field_id from member where name = 'TEST');
        raise notice 'second_inherited_field_id:%', (select second_inheritable_field_id from member where name = 'TEST');
        raise notice 'CTID: %', (select CTID from member where name = 'TEST');
    end if;

    truncate stage;

    raise notice '(LOCATION: AT END) Member Name: TEST - first_inherited_field_id: %)', (select first_inheritable_field_id from member where name = 'TEST');
    raise notice 'second_inherited_field_id:%', (select second_inheritable_field_id from member where name = 'TEST');
    raise notice 'CTID: %', (select CTID from member where name = 'TEST');
end;
$$ LANGUAGE PLPGSQL;

call load_proc_data();

Output:

public> call load_proc_data()
[2025-08-22 11:13:24] [P0001] ERROR: Required fields are null. Member_Id: 1, MEMBER NAME: TEST, first_inheritable_trait_id: <NULL>, second_inheritable_trait_id: <NULL>, parent_member_id: <NULL>
[2025-08-22 11:13:24] Where: PL/pgSQL function required_fields() line 18 at RAISE
(LOCATION: In IF Statement) Member Name: TEST - first_inherited_field_id: 1)
second_inherited_field_id:1
(LOCATION: AT END) Member Name: TEST - first_inherited_field_id: 1)
second_inherited_field_id:1

https://dbfiddle.uk/fLWtO_tP


Solution

  • The constraint rejects all INSERTs coming from the procedure:

    IF new.source_id = app_source_id THEN RETURN NEW;
    ELSEIF new.source_id = proc_source_id
        THEN IF (new.first_inheritable_field_id is not null
             AND new.second_inheritable_field_id is not null)
             AND tg_op ILIKE 'UPDATE'-----------------here
             THEN RETURN NEW;
             ELSE RAISE EXCEPTION ''
             END IF;
    ELSE RETURN NEW;
    END IF;
    

    Even if you managed to fix the two fields, it'd still throw the exception because of the third condition. The trigger fires separately for the insert, then the updates, even though they concern the same row.

    The less obvious problems:

    In this example, the reason deferred unique works fine if you insert a duplicate then update it to fix that, is because Postgres sees the original row with the offending value got invalidated. In your case, you're only checking the unchanged new record, expecting it to reflect consequent updates. Here's another example where you see even though the row got invalidated and it worked with a deferred unique, a constraint trigger checking new doesn't work the same.

    A simple way to to fix that would be to not insert at the start of your procedure. Prepare the record in a set of variables per field, or member-type record variable, modify it through assignments throughout the procedure and only insert it at the end, when its final, non-violating state is ready.