wiki:AdvancedDatabaseDevelopment

Version 2 (modified by 233149, 12 days ago) ( diff )

--

Напреден развој на базата

Сите објекти опишани подолу се во скриптата advanced.sql​, која се стартува по schema_creation.sql и data_load.sql. Скриптата е повторлива - секој објект се креира со OR REPLACE или претходно се брише.

Основните ограничувања на ниво на колона и референцијалниот интегритет се документирани во Фаза 2. Овде се опишани само оние правила кои не можат да се изразат со обично ограничување.

1. Повеќе факултети во иста база

Опис на барањето

Системот е замислен да опслужува повеќе факултети од ист универзитет. Секој факултет има свои студенти, професори, предмети, студиски програми и активни семестри. Податоците на еден факултет не смеат да бидат видливи ниту достапни од друг факултет, а притоа сите работат врз иста база и иста шема.

Ова носи три проблеми кои не се решаваат со надворешни клучеви:

  • секое барање мора автоматски да биде ограничено на тековниот факултет, без секој SELECT да мора да памети WHERE faculty_id = ...
  • шифрата на предметот е уникатна во рамки на факултет, а не глобално - два факултета смеат да имаат предмет со иста шифра
  • запишувањето мора да поврзе студент, студиска програма и семестар кои сите припаѓаат на ист факултет - надворешен клуч може да провери дека секој од нив постои, но не и дека се од ист факултет

Имплементација

Табела и колона за наемател (tenant)

CREATE TABLE faculty (
    id         INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    short_name VARCHAR(20)  NOT NULL UNIQUE,
    full_name  VARCHAR(200) NOT NULL,
    university VARCHAR(200) NOT NULL
);

ALTER TABLE users            ADD COLUMN faculty_id INTEGER NOT NULL DEFAULT 1 REFERENCES faculty (id);
ALTER TABLE major            ADD COLUMN faculty_id INTEGER NOT NULL DEFAULT 1 REFERENCES faculty (id);
ALTER TABLE subjects         ADD COLUMN faculty_id INTEGER NOT NULL DEFAULT 1 REFERENCES faculty (id);
ALTER TABLE active_semesters ADD COLUMN faculty_id INTEGER NOT NULL DEFAULT 1 REFERENCES faculty (id);
ALTER TABLE documents        ADD COLUMN faculty_id INTEGER NOT NULL DEFAULT 1 REFERENCES faculty (id);

Колоната faculty_id се додава само на кореновите ентитети. Сите останати табели го наследуваат факултетот преку нив, па не се повторува насекаде.

Уникатност во рамки на факултет

ALTER TABLE subjects DROP CONSTRAINT subjects_code_key;
ALTER TABLE subjects DROP CONSTRAINT subjects_name_key;

CREATE UNIQUE INDEX subjects_faculty_code_key ON subjects (faculty_id, code);
CREATE UNIQUE INDEX subjects_faculty_name_key ON subjects (faculty_id, name);

Функција и процедура за тековен факултет

CREATE OR REPLACE FUNCTION current_faculty()
    RETURNS INTEGER
    LANGUAGE plpgsql STABLE
AS $$
BEGIN
    RETURN nullif(current_setting('app.current_faculty', TRUE), '')::INTEGER;
EXCEPTION
    WHEN others THEN RETURN NULL;
END;
$$;

CREATE OR REPLACE PROCEDURE set_current_faculty(p_faculty INTEGER)
    LANGUAGE plpgsql
AS $$
BEGIN
    IF p_faculty IS NOT NULL AND NOT EXISTS (SELECT 1 FROM faculty WHERE id = p_faculty) THEN
        RAISE EXCEPTION 'Faculty % does not exist', p_faculty;
    END IF;
    PERFORM set_config('app.current_faculty', coalesce(p_faculty::TEXT, ''), FALSE);
END;
$$;

Политики за безбедност на ниво на редица (Row Level Security)

ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON users
    USING (current_faculty() IS NULL OR faculty_id = current_faculty());

Истата политика се поставува и врз major, subjects, active_semesters и documents. Кога апликацијата ќе повика CALL set_current_faculty(2), сите барања во таа сесија автоматски гледаат само податоци од факултет 2, без ниту една измена во самите SQL барања.

Забелешка: сопственикот на табелата ги заобиколува политиките, освен ако не се употреби FORCE ROW LEVEL SECURITY. Затоа во продукција апликацијата се поврзува со посебна улога која не е сопственик на шемата.

Тригер за интегритет меѓу факултети

CREATE OR REPLACE FUNCTION check_enrolment_same_faculty()
    RETURNS TRIGGER
    LANGUAGE plpgsql
AS $$
DECLARE
    f_student  INTEGER;
    f_major    INTEGER;
    f_semester INTEGER;
BEGIN
    SELECT faculty_id INTO f_student  FROM users            WHERE id = NEW.user_id;
    SELECT faculty_id INTO f_major    FROM major            WHERE id = NEW.major_id;
    SELECT faculty_id INTO f_semester FROM active_semesters WHERE id = NEW.semester_id;

    IF f_student IS DISTINCT FROM f_major OR f_student IS DISTINCT FROM f_semester THEN
        RAISE EXCEPTION
            'Cross-faculty enrolment: student belongs to faculty %, major to %, semester to %',
            f_student, f_major, f_semester;
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_enrolment_same_faculty
    BEFORE INSERT OR UPDATE ON enrolled_semesters
    FOR EACH ROW EXECUTE FUNCTION check_enrolment_same_faculty();

2. Правила за запишување семестар

Опис на барањето

Запишувањето на семестар има четири правила кои базата треба сама да ги чува, без да зависи од тоа дали апликацијата ќе ги провери:

  • предметот мора да биде понуден од студиската програма на запишувањето
  • професорот мора навистина да го предава тој предмет во тој семестар
  • предусловите на предметот мора да бидат положени
  • запишувањето смее да има најмногу 5 предмети и најмногу 30 кредити

Ниту едно од овие не може да се напише како CHECK ограничување, бидејќи сите бараат читање од други табели.

Имплементација - тригери

Предметот мора да е во студиската програма

CREATE OR REPLACE FUNCTION check_subject_in_major()
    RETURNS TRIGGER
    LANGUAGE plpgsql
AS $$
DECLARE
    v_major INTEGER;
BEGIN
    SELECT major_id INTO v_major FROM enrolled_semesters WHERE id = NEW.enrolled_semesters_id;

    IF NOT EXISTS (SELECT 1 FROM major_subjects
                    WHERE major_id = v_major AND subject_id = NEW.subjects_id) THEN
        RAISE EXCEPTION 'Subject % is not offered by study programme %',
            NEW.subjects_id, v_major;
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_subject_in_major
    BEFORE INSERT OR UPDATE ON semesters_subjects
    FOR EACH ROW EXECUTE FUNCTION check_subject_in_major();

Професорот мора да го предава предметот тој семестар

CREATE OR REPLACE FUNCTION check_professor_teaches()
    RETURNS TRIGGER
    LANGUAGE plpgsql
AS $$
DECLARE
    v_semester INTEGER;
    v_role     user_role;
BEGIN
    SELECT semester_id INTO v_semester FROM enrolled_semesters WHERE id = NEW.enrolled_semesters_id;
    SELECT role        INTO v_role     FROM users              WHERE id = NEW.professor_id;

    IF v_role <> 'prof' THEN
        RAISE EXCEPTION 'User % is not a professor', NEW.professor_id;
    END IF;

    IF NOT EXISTS (SELECT 1 FROM professour_subjects
                    WHERE prof_id = NEW.professor_id
                      AND subject_id = NEW.subjects_id
                      AND active_semester_id = v_semester) THEN
        RAISE EXCEPTION 'Professor % does not teach subject % in semester %',
            NEW.professor_id, NEW.subjects_id, v_semester;
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_professor_teaches
    BEFORE INSERT OR UPDATE ON semesters_subjects
    FOR EACH ROW EXECUTE FUNCTION check_professor_teaches();

Предусловите мора да се положени

CREATE OR REPLACE FUNCTION check_prerequisites_passed()
    RETURNS TRIGGER
    LANGUAGE plpgsql
AS $$
DECLARE
    v_user    INTEGER;
    v_missing TEXT;
BEGIN
    SELECT user_id INTO v_user FROM enrolled_semesters WHERE id = NEW.enrolled_semesters_id;

    SELECT string_agg(s.code, ', ' ORDER BY s.code) INTO v_missing
    FROM dependency_subject d
    JOIN subjects s ON s.id = d.dependency_id
    WHERE d.subject_id = NEW.subjects_id
      AND NOT EXISTS (
          SELECT 1
          FROM passed_subjects ps
          JOIN semesters_subjects ss ON ss.id = ps.enrolled_id
          JOIN enrolled_semesters es ON es.id = ss.enrolled_semesters_id
          WHERE es.user_id = v_user AND ss.subjects_id = d.dependency_id);

    IF v_missing IS NOT NULL THEN
        RAISE EXCEPTION 'Prerequisite(s) not passed for subject %: %',
            NEW.subjects_id, v_missing;
    END IF;
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_prerequisites_passed
    BEFORE INSERT ON semesters_subjects
    FOR EACH ROW EXECUTE FUNCTION check_prerequisites_passed();

Одложен тригер за големина на запишувањето

Ова правило е поставено како одложен (DEFERRABLE INITIALLY DEFERRED) тригер, што значи дека се проверува дури при потврда на трансакцијата, а не по секој ред. Така апликацијата може да ги внесе петте предмети еден по еден, или да замени еден предмет со друг во иста трансакција, без правилото да се прекрши на средина од работата.

CREATE OR REPLACE FUNCTION check_enrolment_size()
    RETURNS TRIGGER
    LANGUAGE plpgsql
AS $$
DECLARE
    v_enrolment INTEGER := coalesce(NEW.enrolled_semesters_id, OLD.enrolled_semesters_id);
    v_count     INTEGER;
    v_credits   INTEGER;
BEGIN
    SELECT count(*), coalesce(sum(s.awarded_credits), 0)
      INTO v_count, v_credits
    FROM semesters_subjects ss
    JOIN subjects s ON s.id = ss.subjects_id
    WHERE ss.enrolled_semesters_id = v_enrolment;

    IF v_count > 5 THEN
        RAISE EXCEPTION 'Enrolment % has % subjects; the maximum is 5', v_enrolment, v_count;
    END IF;

    IF v_credits > 30 THEN
        RAISE EXCEPTION 'Enrolment % has % credits; the maximum is 30', v_enrolment, v_credits;
    END IF;

    RETURN NULL;
END;
$$;

CREATE CONSTRAINT TRIGGER trg_enrolment_size
    AFTER INSERT OR UPDATE OR DELETE ON semesters_subjects
    DEFERRABLE INITIALLY DEFERRED
    FOR EACH ROW EXECUTE FUNCTION check_enrolment_size();

3. Правила за оценување и известувања

Опис на барањето

  • оценка не смее да има датум во иднина
  • оценка смее да внесе само професорот кој го предава тој предмет на тој студент
  • положен предмет автоматски значи дека има потпис
  • студентот треба веднаш да добие известување кога ќе биде оценет

Втората точка е проверка на овластување во самата база. Апликацијата и онака ја прави истата проверка, но доколку некој пристапи до базата директно, правилото пак важи.

Имплементација - тригери

CREATE OR REPLACE FUNCTION check_grade_rules()
    RETURNS TRIGGER
    LANGUAGE plpgsql
AS $$
DECLARE
    v_professor INTEGER;
    v_actor     INTEGER;
BEGIN
    IF NEW.date_passed > now() THEN
        RAISE EXCEPTION 'A grade cannot be dated in the future (%)', NEW.date_passed;
    END IF;

    SELECT professor_id INTO v_professor
    FROM semesters_subjects WHERE id = NEW.enrolled_id;

    v_actor := nullif(current_setting('app.current_user_id', TRUE), '')::INTEGER;
    IF v_actor IS NOT NULL AND v_actor <> v_professor THEN
        RAISE EXCEPTION 'User % may not grade this subject; it is taught by %',
            v_actor, v_professor;
    END IF;

    UPDATE semesters_subjects SET signature = TRUE WHERE id = NEW.enrolled_id;

    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_grade_rules
    BEFORE INSERT OR UPDATE ON passed_subjects
    FOR EACH ROW EXECUTE FUNCTION check_grade_rules();

Проверката на овластување се активира само кога апликацијата ќе ѝ каже на базата кој работи, преку SET app.current_user_id. Така скриптите за одржување и вчитување податоци не се блокирани, а вистинските барања од апликацијата се проверени и на ниво на база.

Известување на студентот

CREATE TABLE notification (
    id         INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    user_id    INTEGER      NOT NULL REFERENCES users (id),
    kind       VARCHAR(40)  NOT NULL,
    message    TEXT         NOT NULL,
    is_read    BOOLEAN      NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP    NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION notify_student_graded()
    RETURNS TRIGGER
    LANGUAGE plpgsql
AS $$
DECLARE
    v_user    INTEGER;
    v_subject TEXT;
BEGIN
    SELECT es.user_id, s.name
      INTO v_user, v_subject
    FROM semesters_subjects ss
    JOIN enrolled_semesters es ON es.id = ss.enrolled_semesters_id
    JOIN subjects s            ON s.id  = ss.subjects_id
    WHERE ss.id = NEW.enrolled_id;

    INSERT INTO notification (user_id, kind, message)
    VALUES (v_user, 'grade',
            format('Добивте оценка %s по предметот %s.', NEW.grade, v_subject));
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_notify_graded
    AFTER INSERT ON passed_subjects
    FOR EACH ROW EXECUTE FUNCTION notify_student_graded();

4. Прегледи (views)

Опис на барањето

Најчестите извештаи во апликацијата бараат спојување на пет до шест табели. Наместо истото барање да се повторува на повеќе места во кодот, тие се дефинирани како прегледи во базата.

Имплементација

Уверение за положени испити

CREATE OR REPLACE VIEW v_student_transcript AS
SELECT es.user_id,
       u."index"                    AS student_index,
       u.name || ' ' || u.surname   AS student,
       s.code                       AS subject_code,
       s.name                       AS subject,
       s.awarded_credits,
       ps.grade,
       ps.date_passed,
       p.name || ' ' || p.surname   AS professor,
       a.year,
       a.type                       AS semester_type
FROM passed_subjects ps
JOIN semesters_subjects ss ON ss.id = ps.enrolled_id
JOIN enrolled_semesters es ON es.id = ss.enrolled_semesters_id
JOIN users u               ON u.id  = es.user_id
JOIN subjects s            ON s.id  = ss.subjects_id
JOIN users p               ON p.id  = ss.professor_id
JOIN active_semesters a    ON a.id  = es.semester_id;

Состојба на студентот - кредити и просек

CREATE OR REPLACE VIEW v_student_standing AS
SELECT u.id                                          AS user_id,
       u."index"                                     AS student_index,
       u.name || ' ' || u.surname                    AS student,
       count(ps.id)                                  AS passed_subjects,
       coalesce(sum(s.awarded_credits), 0)           AS credits,
       round(avg(ps.grade::TEXT::INT)::NUMERIC, 2)   AS average_grade
FROM users u
LEFT JOIN enrolled_semesters es ON es.user_id = u.id
LEFT JOIN semesters_subjects ss ON ss.enrolled_semesters_id = es.id
LEFT JOIN passed_subjects ps    ON ps.enrolled_id = ss.id
LEFT JOIN subjects s            ON s.id = ss.subjects_id AND ps.id IS NOT NULL
WHERE u.role = 'student'
GROUP BY u.id, u."index", u.name, u.surname;

Двојното претворање ps.grade::TEXT::INT е потребно бидејќи grade е набројувачки тип, па не може директно да влезе во avg().

Дневник на професорот и покриеност на семестарот

CREATE OR REPLACE VIEW v_professor_gradebook AS
SELECT ss.professor_id,
       p.name || ' ' || p.surname   AS professor,
       s.code                       AS subject_code,
       s.name                       AS subject,
       a.year, a.type               AS semester_type,
       u.id                         AS student_id,
       u."index"                    AS student_index,
       u.name || ' ' || u.surname   AS student,
       ss.signature,
       ps.grade
FROM semesters_subjects ss
JOIN enrolled_semesters es ON es.id = ss.enrolled_semesters_id
JOIN users u               ON u.id  = es.user_id
JOIN users p               ON p.id  = ss.professor_id
JOIN subjects s            ON s.id  = ss.subjects_id
JOIN active_semesters a    ON a.id  = es.semester_id
LEFT JOIN passed_subjects ps ON ps.enrolled_id = ss.id;

CREATE OR REPLACE VIEW v_semester_coverage AS
SELECT a.id AS semester_id, a.year, a.type AS semester_type,
       s.id AS subject_id, s.code AS subject_code, s.name AS subject
FROM active_semesters a
CROSS JOIN subjects s
WHERE NOT EXISTS (SELECT 1 FROM professour_subjects ps
                   WHERE ps.active_semester_id = a.id AND ps.subject_id = s.id);

Прегледот v_semester_coverage покажува кои предмети никој не ги предава во даден семестар. Тоа е точно списокот кој администраторот мора да го исчисти, бидејќи студент не може да запише предмет без професор.

5. Статистика по предмети (материјализиран преглед)

Опис на барањето

Статистиката за проодност по предмет се пресметува преку сите запишувања и сите оценки. Тоа е скапо барање кое не се менува од минута во минута, па се чува материјализирано и се освежува еднаш дневно.

Имплементација

CREATE MATERIALIZED VIEW mv_subject_statistics AS
SELECT s.id                                                     AS subject_id,
       s.code                                                   AS subject_code,
       s.name                                                   AS subject,
       count(ss.id)                                             AS enrolled_count,
       count(ps.id)                                             AS passed_count,
       round(100.0 * count(ps.id) / nullif(count(ss.id), 0), 1) AS pass_rate,
       round(avg(ps.grade::TEXT::INT)::NUMERIC, 2)              AS average_grade
FROM subjects s
LEFT JOIN semesters_subjects ss ON ss.subjects_id = s.id
LEFT JOIN passed_subjects ps    ON ps.enrolled_id = ss.id
GROUP BY s.id, s.code, s.name;

CREATE UNIQUE INDEX mv_subject_statistics_pk ON mv_subject_statistics (subject_id);

Уникатниот индекс не е само оптимизација - тој е услов за да може прегледот да се освежува со REFRESH MATERIALIZED VIEW CONCURRENTLY, односно без да се заклучи за читање додека трае освежувањето.

6. Ноќно одржување (background jobs)

Опис на барањето

Четири работи треба да се случуваат периодично, без некој да ги повика рачно:

  • истечените токени за освежување да се поништат
  • запишување во кое сите предмети се положени да се затвори
  • студентите да добијат потсетник за предмети без потпис
  • статистиката да се пресмета одново

Имплементација - процедури

CREATE OR REPLACE PROCEDURE expire_old_tokens()
    LANGUAGE plpgsql
AS $$
DECLARE
    v_expired INTEGER;
BEGIN
    UPDATE token SET is_valid = FALSE
    WHERE is_valid = TRUE AND expires_at < now();

    GET DIAGNOSTICS v_expired = ROW_COUNT;
    RAISE NOTICE 'expire_old_tokens: invalidated % token(s)', v_expired;
END;
$$;

CREATE OR REPLACE PROCEDURE close_completed_enrolments()
    LANGUAGE plpgsql
AS $$
DECLARE
    v_closed INTEGER;
BEGIN
    WITH finished AS (
        SELECT es.id
        FROM enrolled_semesters es
        JOIN semesters_subjects ss ON ss.enrolled_semesters_id = es.id
        LEFT JOIN passed_subjects ps ON ps.enrolled_id = ss.id
        WHERE es.completed IS NULL
        GROUP BY es.id
        HAVING count(ss.id) > 0 AND count(ss.id) = count(ps.id)
    )
    UPDATE enrolled_semesters es
    SET completed = now(), last_change = now()
    FROM finished f
    WHERE es.id = f.id;

    GET DIAGNOSTICS v_closed = ROW_COUNT;
    RAISE NOTICE 'close_completed_enrolments: closed % enrolment(s)', v_closed;
END;
$$;

Процедурата notify_missing_signatures создава известување за секој предмет без потпис, но само ако таков потсетник сè уште не постои, за да не се праќа истото известување секоја ноќ.

Сите четири се повикуваат од една влезна точка, која потоа ја стартува распоредувачот (pg_cron или закажана задача на оперативниот систем):

CREATE OR REPLACE PROCEDURE run_nightly_maintenance()
    LANGUAGE plpgsql
AS $$
BEGIN
    CALL expire_old_tokens();
    CALL close_completed_enrolments();
    CALL notify_missing_signatures();
    CALL refresh_statistics();
    RAISE NOTICE 'run_nightly_maintenance: done';
END;
$$;

7. Функции за извештаи

Функцијата student_eligible_subjects враќа табела со сите предмети од студиската програма на студентот, и за секој кажува дали е веќе положен, дали предусловите се исполнети и дали некој го предава во бараниот семестар. Екранот за запишување со еден повик добива сè што му треба, наместо да собира три различни барања.

SELECT * FROM student_eligible_subjects(1, 3);

Функцијата student_summary враќа еден ред со бројот на положени предмети, вкупните кредити и просекот на еден студент.

8. Сопствени домени

Опис на барањето

Проверките на формат се повторуваат на повеќе места: е-пошта има две колони (users.email и contact.microsoft_email), износ има две (payment.amount и documents.cost). Наместо истиот CHECK да се пишува на секое место, правилото се дефинира еднаш како домен и потоа се употребува како тип.

Имплементација

CREATE DOMAIN email_address AS VARCHAR(150)
    CHECK (VALUE ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$');

CREATE DOMAIN embg_number AS VARCHAR(13)
    CHECK (VALUE ~ '^[0-9]{13}$');

CREATE DOMAIN student_index AS VARCHAR(20)
    CHECK (VALUE ~ '^[0-9]{6}$');

CREATE DOMAIN phone_number AS VARCHAR(20)
    CHECK (VALUE ~ '^[0-9+][0-9 /-]{5,19}$');

CREATE DOMAIN money_amount AS INTEGER
    CHECK (VALUE >= 0);

CREATE DOMAIN gpa_value AS REAL
    CHECK (VALUE >= 2.0 AND VALUE <= 5.0);

CREATE DOMAIN credit_points AS INTEGER
    CHECK (VALUE > 0 AND VALUE <= 30);

Афектирани колони:

ALTER TABLE users       ALTER COLUMN email           TYPE email_address;
ALTER TABLE users       ALTER COLUMN embg            TYPE embg_number;
ALTER TABLE users       ALTER COLUMN "index"         TYPE student_index;
ALTER TABLE contact     ALTER COLUMN microsoft_email TYPE email_address;
ALTER TABLE contact     ALTER COLUMN number          TYPE phone_number;
ALTER TABLE payment     ALTER COLUMN amount          TYPE money_amount;
ALTER TABLE documents   ALTER COLUMN cost            TYPE money_amount;
ALTER TABLE high_school ALTER COLUMN gpa             TYPE gpa_value;
ALTER TABLE subjects    ALTER COLUMN awarded_credits TYPE credit_points;

Историјат

Верзија 1 - Прва верзија: домени, повеќе факултети со политики на ниво на редица, тригери за правилата на запишување и оценување, прегледи, материјализиран преглед за статистика и процедури за ноќно одржување.

Статус

Во тек

Attachments (1)

Download all attachments as: .zip

Note: See TracWiki for help on using the wiki.