Skip to content

Lab 04: User-Defined Functions & Triggers

Field Details
Course ITEC-445 โ€” Advanced Database Management
Week 4
Difficulty โญโญโญ Advanced
Estimated Time 85 minutes
Topic User-Defined Functions & Triggers
Prerequisites Lab 01 schema on Neon branch lab-04
Deliverables 4 UDFs, 3 triggers, verify_lab04() PASS

Overview

Functions extend SQL's vocabulary โ€” reusable calculations you can call anywhere a value is expected. Triggers automate database behavior invisibly: auditing changes, enforcing business rules, and maintaining denormalized data. This lab builds the computed-value and automation layer for the Frostburg University database.


Branch Requirement

Create branch lab-04 with Lab 01 schema populated. Run Lab 01 seed SQL + Lab 02 grade_points view.


Part A โ€” Scalar Functions (30 pts)

A1. GPA calculator: Write calculate_gpa(p_student_id INT) RETURNS NUMERIC(3,2) โ€” computes a student's GPA from their grade_points view rows. Return NULL if no graded courses.

SET search_path = fsu;

CREATE OR REPLACE FUNCTION calculate_gpa(p_student_id INT)
RETURNS NUMERIC(3,2)
LANGUAGE plpgsql STABLE AS $$
DECLARE
    v_gpa NUMERIC(3,2);
BEGIN
    SELECT ROUND(AVG(points)::NUMERIC, 2) INTO v_gpa
    FROM grade_points
    WHERE student_id = p_student_id AND points IS NOT NULL;
    RETURN v_gpa;
END;
$$;

-- Test
SELECT student_id, first_name, gpa AS stored_gpa,
       calculate_gpa(student_id) AS computed_gpa
FROM fsu.students ORDER BY student_id;

A2. Letter grade converter: Write points_to_letter(p_points NUMERIC) RETURNS VARCHAR(2) โ€” converts a 0โ€“4.0 GPA point value back to the letter grade string. Handle edge cases (NULL, out-of-range).

A3. Enrollment status: Write enrollment_status(p_student_id INT, p_semester VARCHAR) RETURNS TEXT โ€” returns 'Active' if enrolled in โ‰ฅ1 course that semester, 'Inactive' otherwise, 'Invalid Student' if student_id doesn't exist.

A4. Credit hours: Write total_credits(p_student_id INT, p_semester VARCHAR) RETURNS INT โ€” returns the total credit hours a student is taking in a given semester by joining enrollments and courses.


Part B โ€” Table-Valued Function (15 pts)

Write student_transcript(p_student_id INT) returning a table of all enrollment records formatted as a transcript:

CREATE OR REPLACE FUNCTION student_transcript(p_student_id INT)
RETURNS TABLE(
    semester    VARCHAR,
    course_code VARCHAR,
    title       VARCHAR,
    credits     SMALLINT,
    grade       VARCHAR,
    points      NUMERIC,
    semester_gpa NUMERIC
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
    RETURN QUERY
    SELECT
        e.semester,
        c.course_code,
        c.title,
        c.credits,
        e.grade,
        gp.points,
        ROUND(AVG(gp.points) OVER (PARTITION BY e.semester), 2) AS semester_gpa
    FROM enrollments e
    JOIN courses c     ON e.course_id = c.course_id
    JOIN grade_points gp ON e.enrollment_id = gp.enrollment_id
    WHERE e.student_id = p_student_id
    ORDER BY e.semester, c.course_code;
END;
$$;

-- Test: show Lena Adams's full transcript
SELECT * FROM student_transcript(1);

Part C โ€” Trigger: Audit Log (25 pts)

Create a trigger that logs every INSERT, UPDATE, and DELETE on the enrollments table into a enrollment_audit table.

CREATE TABLE IF NOT EXISTS enrollment_audit (
    audit_id     SERIAL PRIMARY KEY,
    operation    VARCHAR(6) NOT NULL,  -- INSERT, UPDATE, DELETE
    enrollment_id INT,
    student_id   INT,
    course_id    INT,
    semester     VARCHAR(10),
    old_grade    VARCHAR(2),
    new_grade    VARCHAR(2),
    changed_by   TEXT DEFAULT current_user,
    changed_at   TIMESTAMPTZ DEFAULT NOW()
);

CREATE OR REPLACE FUNCTION trg_enrollment_audit()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO enrollment_audit
            (operation, enrollment_id, student_id, course_id, semester, new_grade)
        VALUES ('INSERT', NEW.enrollment_id, NEW.student_id, NEW.course_id, NEW.semester, NEW.grade);
        RETURN NEW;

    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO enrollment_audit
            (operation, enrollment_id, student_id, course_id, semester, old_grade, new_grade)
        VALUES ('UPDATE', NEW.enrollment_id, NEW.student_id, NEW.course_id,
                NEW.semester, OLD.grade, NEW.grade);
        RETURN NEW;

    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO enrollment_audit
            (operation, enrollment_id, student_id, course_id, semester, old_grade)
        VALUES ('DELETE', OLD.enrollment_id, OLD.student_id, OLD.course_id,
                OLD.semester, OLD.grade);
        RETURN OLD;
    END IF;
END;
$$;

CREATE TRIGGER enrollment_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON enrollments
FOR EACH ROW EXECUTE FUNCTION trg_enrollment_audit();

Test it:

-- INSERT
INSERT INTO fsu.enrollments (student_id, course_id, semester) VALUES (6, 1, '2025SP');

-- UPDATE (assign a grade)
UPDATE fsu.enrollments SET grade = 'A-'
WHERE student_id = 6 AND course_id = 1 AND semester = '2025SP';

-- DELETE
DELETE FROM fsu.enrollments WHERE student_id = 6 AND course_id = 1 AND semester = '2025SP';

-- View audit log
SELECT * FROM fsu.enrollment_audit ORDER BY changed_at;

Verify 3 rows appear in enrollment_audit (INSERT, UPDATE, DELETE).


Part D โ€” Trigger: BEFORE Validation (20 pts)

Write a BEFORE INSERT OR UPDATE trigger on students that: 1. Rejects any update that would set GPA outside the range 0.00โ€“4.00 2. Normalizes the email to lowercase automatically 3. Prevents enrollment year from being in the future

CREATE OR REPLACE FUNCTION trg_student_validate()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
    -- Normalize email
    NEW.email := LOWER(TRIM(NEW.email));

    -- Validate GPA range
    IF NEW.gpa IS NOT NULL AND (NEW.gpa < 0 OR NEW.gpa > 4.0) THEN
        RAISE EXCEPTION 'GPA % is outside valid range 0.00-4.00', NEW.gpa;
    END IF;

    -- Validate enrollment year
    IF NEW.enroll_year > EXTRACT(YEAR FROM NOW()) THEN
        RAISE EXCEPTION 'Enrollment year % cannot be in the future', NEW.enroll_year;
    END IF;

    RETURN NEW;
END;
$$;

CREATE TRIGGER student_validate_trigger
BEFORE INSERT OR UPDATE ON students
FOR EACH ROW EXECUTE FUNCTION trg_student_validate();

Test it โ€” each should behave as noted:

-- Should FAIL (GPA > 4.0)
UPDATE fsu.students SET gpa = 4.5 WHERE student_id = 1;

-- Should FAIL (future year)
UPDATE fsu.students SET enroll_year = 2099 WHERE student_id = 1;

-- Should SUCCEED and normalize email to lowercase
UPDATE fsu.students SET email = 'LADAMS@STUDENT.FSU.EDU' WHERE student_id = 1;
SELECT email FROM fsu.students WHERE student_id = 1;  -- should be lowercase


Part E โ€” Trigger: Auto-Update GPA (10 pts)

Write an AFTER INSERT OR UPDATE OR DELETE trigger on enrollments that automatically calls recalculate_all_gpas() โ€” or better, recalculates only the affected student's GPA โ€” whenever a grade changes:

CREATE OR REPLACE FUNCTION trg_update_student_gpa()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
    v_student_id INT;
    v_new_gpa    NUMERIC(3,2);
BEGIN
    -- Determine which student was affected
    v_student_id := COALESCE(NEW.student_id, OLD.student_id);

    -- Recalculate just this student's GPA
    SELECT ROUND(AVG(points)::NUMERIC, 2) INTO v_new_gpa
    FROM fsu.grade_points
    WHERE student_id = v_student_id AND points IS NOT NULL;

    UPDATE fsu.students SET gpa = v_new_gpa WHERE student_id = v_student_id;
    RETURN COALESCE(NEW, OLD);
END;
$$;

CREATE TRIGGER update_gpa_on_grade_change
AFTER INSERT OR UPDATE OF grade OR DELETE ON enrollments
FOR EACH ROW EXECUTE FUNCTION trg_update_student_gpa();

Test it:

-- Check Lena Adams's current GPA
SELECT gpa FROM fsu.students WHERE student_id = 1;

-- Change a grade
UPDATE fsu.enrollments SET grade = 'F'
WHERE student_id = 1 AND semester = '2024FA' AND course_id = 1;

-- GPA should auto-update (drop significantly)
SELECT gpa FROM fsu.students WHERE student_id = 1;

-- Restore
UPDATE fsu.enrollments SET grade = 'A'
WHERE student_id = 1 AND semester = '2024FA' AND course_id = 1;
SELECT gpa FROM fsu.students WHERE student_id = 1;


Verification

SET search_path = fsu;

CREATE OR REPLACE FUNCTION verify_lab04()
RETURNS TABLE(check_id TEXT, description TEXT, result TEXT, points INT) AS $$
BEGIN
    RETURN QUERY SELECT '01', 'calculate_gpa function exists',
        CASE WHEN EXISTS(SELECT 1 FROM pg_proc WHERE proname='calculate_gpa')
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '02', 'calculate_gpa(1) returns non-null',
        CASE WHEN calculate_gpa(1) IS NOT NULL THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '03', 'points_to_letter function exists',
        CASE WHEN EXISTS(SELECT 1 FROM pg_proc WHERE proname='points_to_letter')
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '04', 'student_transcript function returns rows for student 1',
        CASE WHEN (SELECT COUNT(*) FROM student_transcript(1)) > 0
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '05', 'enrollment_audit table exists',
        CASE WHEN EXISTS(SELECT 1 FROM information_schema.tables
                         WHERE table_schema='fsu' AND table_name='enrollment_audit')
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '06', 'enrollment_audit trigger fires on INSERT',
        CASE WHEN (SELECT COUNT(*) FROM enrollment_audit WHERE operation='INSERT') > 0
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '07', 'enrollment_audit trigger fires on UPDATE',
        CASE WHEN (SELECT COUNT(*) FROM enrollment_audit WHERE operation='UPDATE') > 0
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '08', 'student_validate_trigger exists',
        CASE WHEN EXISTS(SELECT 1 FROM pg_trigger WHERE tgname='student_validate_trigger')
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '09', 'email normalized to lowercase by trigger',
        CASE WHEN NOT EXISTS(SELECT 1 FROM students WHERE email != LOWER(email))
             THEN 'PASS' ELSE 'FAIL' END, 10;

    RETURN QUERY SELECT '10', 'update_gpa_on_grade_change trigger exists',
        CASE WHEN EXISTS(SELECT 1 FROM pg_trigger WHERE tgname='update_gpa_on_grade_change')
             THEN 'PASS' ELSE 'FAIL' END, 10;
END;
$$ LANGUAGE plpgsql;

SELECT check_id, description, result,
       CASE WHEN result = 'PASS' THEN points ELSE 0 END AS earned
FROM verify_lab04()
ORDER BY check_id;

Additional Requirement (20 pts)

Write a function dean_list(p_semester VARCHAR, p_min_gpa NUMERIC DEFAULT 3.5) RETURNS TABLE that returns all students who qualify for the dean's list for a given semester โ€” defined as having a semester GPA of at least p_min_gpa and being enrolled in at least 12 credit hours that semester.

Columns: student_id, full_name, dept_name, semester_gpa, credit_hours.


Submission Checklist

  • [ ] Neon branch lab-04 with all functions and triggers created
  • [ ] All test statements run with screenshots showing expected behavior
  • [ ] verify_lab04() screenshot โ€” all PASS
  • [ ] lab04_bonus.sql โ€” dean_list function

Grading

Component Points
Part A โ€” 4 scalar functions (correct logic, handles NULLs) 30
Part B โ€” student_transcript table-valued function 15
Part C โ€” enrollment_audit trigger (INSERT/UPDATE/DELETE) 25
Part D โ€” BEFORE validation trigger (3 rules enforced) 20
Part E โ€” auto-GPA update trigger 10
Additional requirement โ€” dean_list function 20
Total 120