Index: docs/Ph6.md
===================================================================
--- docs/Ph6.md	(revision e7bafc08ad74099b6daf91b8414b90e3c4968111)
+++ 	(revision )
@@ -1,910 +1,0 @@
-== Напредни извештаи од базата (SQL, складирани процедури и релациона алгебра)
-
-Сите извештаи подолу се напишани и тестирани врз шемата '''project''' на доделената
-проектна база, врз податоците од [attachment:data_load.sql].
-
-Секој извештај е даден во три форми:
-
- * '''SQL''' - барањето како што се извршува врз базата
- * '''Релациона алгебра''' - истото барање изразено со операторите на релациона алгебра
- * '''Складирана процедура''' - барањето спакувано како рутина во базата, за да апликацијата го повикува со едно име наместо да го носи барањето во кодот
-
-Сите рутини се во скриптата [attachment:reports.sql], која се стартува по
-schema_creation.sql и data_load.sql. Скриптата е повторлива - секоја рутина се
-креира со OR REPLACE.
-
-Заедничка забелешка за сите извештаи: оценката е од набројувачки тип (grade_type),
-па за пресметка на просек мора двојно да се претвори - {{{ps.grade::TEXT::INT}}}.
-
-== 1. Проодност по предмет и по семестар со отстапување од просекот на предметот
-
-=== Опис на извештајот
-
-За секој предмет и секој семестар во кој тој бил слушан се прикажува колку студенти
-го запишале, колку добиле потпис, колку го положиле, процентот на проодност и
-просечната оценка. Последната колона покажува колку просекот во тој семестар
-отстапува од вкупниот просек на предметот, што покажува дали предметот во определен
-семестар бил полесен или потежок од вообичаеното.
-
-=== SQL
-
-{{{#!sql
-SET search_path TO project;
-
-WITH enrolled_per_semester AS (
-    SELECT ss.subjects_id                      AS subject_id,
-           es.semester_id                      AS semester_id,
-           COUNT(ss.id)                        AS enrolled,
-           COUNT(ps.id)                        AS passed,
-           ROUND(AVG(ps.grade::TEXT::INT), 2)  AS average_grade
-    FROM semesters_subjects ss
-    JOIN enrolled_semesters es   ON es.id = ss.enrolled_semesters_id
-    LEFT JOIN passed_subjects ps ON ps.enrolled_id = ss.id
-    GROUP BY ss.subjects_id, es.semester_id
-),
-signed_per_semester AS (
-    SELECT ss.subjects_id  AS subject_id,
-           es.semester_id  AS semester_id,
-           COUNT(ss.id)    AS signed_count
-    FROM semesters_subjects ss
-    JOIN enrolled_semesters es ON es.id = ss.enrolled_semesters_id
-    WHERE ss.signature = TRUE
-    GROUP BY ss.subjects_id, es.semester_id
-),
-subject_average AS (
-    SELECT ss.subjects_id                      AS subject_id,
-           ROUND(AVG(ps.grade::TEXT::INT), 2)  AS subject_average
-    FROM semesters_subjects ss
-    JOIN passed_subjects ps ON ps.enrolled_id = ss.id
-    GROUP BY ss.subjects_id
-)
-SELECT s.code                                                      AS subject_code,
-       s.name                                                      AS subject,
-       a.year,
-       a.type                                                      AS semester_type,
-       eps.enrolled,
-       COALESCE(sps.signed_count, 0)                               AS signed_count,
-       eps.passed,
-       ROUND(100.0 * eps.passed / eps.enrolled, 1)                 AS pass_rate,
-       COALESCE(CAST(eps.average_grade AS VARCHAR), 'нема оценки')  AS average_grade,
-       COALESCE(CAST(eps.average_grade - sa.subject_average AS VARCHAR), 'n/a') AS deviation
-FROM enrolled_per_semester eps
-JOIN subjects s         ON s.id = eps.subject_id
-JOIN active_semesters a ON a.id = eps.semester_id
-LEFT JOIN signed_per_semester sps ON sps.subject_id  = eps.subject_id
-                                 AND sps.semester_id = eps.semester_id
-LEFT JOIN subject_average sa      ON sa.subject_id   = eps.subject_id
-ORDER BY a.year, a.type, pass_rate DESC, s.code;
-}}}
-
-=== Релациона алгебра
-
-{{{
-EnrolledPerSemester <-
-γ subject_id := ss.subjects_id,
-  semester_id := es.semester_id;
-  enrolled := COUNT(ss.id),
-  passed := COUNT(ps.id),
-  average_grade := AVG(ps.grade)
-(
-  (
-    semesters_subjects ss
-    ⨝ (ss.enrolled_semesters_id = es.id) enrolled_semesters es
-  )
-  ⟕ (ss.id = ps.enrolled_id) passed_subjects ps
-)
-
-SignedPerSemester <-
-γ subject_id := ss.subjects_id,
-  semester_id := es.semester_id;
-  signed_count := COUNT(ss.id)
-(
-  σ ss.signature = TRUE
-  (
-    semesters_subjects ss
-    ⨝ (ss.enrolled_semesters_id = es.id) enrolled_semesters es
-  )
-)
-
-SubjectAverage <-
-γ subject_id := ss.subjects_id;
-  subject_average := AVG(ps.grade)
-(
-  semesters_subjects ss ⨝ (ss.id = ps.enrolled_id) passed_subjects ps
-)
-
-Result <-
-τ a.year, a.type, pass_rate DESC, s.code
-(
-  π s.code,
-    s.name,
-    a.year,
-    a.type,
-    eps.enrolled,
-    sps.signed_count,
-    eps.passed,
-    pass_rate := (eps.passed * 100) / eps.enrolled,
-    eps.average_grade,
-    deviation := eps.average_grade − sa.subject_average
-  (
-    (
-      (
-        (
-          EnrolledPerSemester eps ⨝ (eps.subject_id = s.id) subjects s
-        )
-        ⨝ (eps.semester_id = a.id) active_semesters a
-      )
-      ⟕ (eps.subject_id = sps.subject_id ∧
-         eps.semester_id = sps.semester_id) SignedPerSemester sps
-    )
-    ⟕ (eps.subject_id = sa.subject_id) SubjectAverage sa
-  )
-)
-}}}
-
-=== Складирана процедура
-
-{{{#!sql
-CREATE OR REPLACE FUNCTION rep_subject_pass_rate()
-    RETURNS TABLE (
-        subject_code   VARCHAR,
-        subject        VARCHAR,
-        year           INTEGER,
-        semester_type  semester_type,
-        enrolled       BIGINT,
-        signed_count   BIGINT,
-        passed         BIGINT,
-        pass_rate      NUMERIC,
-        average_grade  VARCHAR,
-        deviation      VARCHAR
-    )
-    LANGUAGE sql
-    STABLE
-    SET search_path = project
-AS $$
-    -- барањето прикажано погоре
-$$;
-
-SELECT * FROM rep_subject_pass_rate();
-}}}
-
-Функцијата е означена како STABLE бидејќи само чита, и има сопствена патека
-{{{SET search_path = project}}}, па повикувачот не мора однапред да ја постави.
-
-=== Резултат врз тест податоците
-
-{{{
- subject_code |           subject           | year | semester_type | enrolled | signed_count | passed | pass_rate | average_grade | deviation
---------------+-----------------------------+------+---------------+----------+--------------+--------+-----------+---------------+-----------
- F18L1S001    | Structured Programming      | 2024 | winter        |        3 |            3 |      3 |     100.0 | 8.67          | 0.00
- F18L2S011    | Operating Systems           | 2024 | winter        |        1 |            1 |      1 |     100.0 | 8.00          | 0.00
- F18L2S010    | Databases                   | 2024 | winter        |        1 |            0 |      0 |       0.0 | нема оценки   | n/a
- F18L1S002    | Object Oriented Programming | 2025 | summer        |        1 |            0 |      0 |       0.0 | нема оценки   | n/a
-}}}
-
-== 2. Досие на студент - студии, финансии и документи во еден ред
-
-=== Опис на извештајот
-
-За секој студент се собираат податоци кои инаку се расфрлани низ пет различни
-табели: колку семестри запишал и колку завршил, колку предмети положил, колку му
-остануваат неположени, колку кредити собрал, каков просек има, колку вкупно платил
-и колку документи подигнал. Секој од овие броеви се пресметува во посебен
-подизраз, а потоа сите се спојуваат со надворешно спојување, за студент кој нема
-ниту еден запис во некоја од табелите сепак да се појави во извештајот.
-
-=== SQL
-
-{{{#!sql
-SET search_path TO project;
-
-WITH passed_stats AS (
-    SELECT es.user_id,
-           COUNT(ps.id)                        AS passed_subjects,
-           SUM(s.awarded_credits)              AS credits,
-           ROUND(AVG(ps.grade::TEXT::INT), 2)  AS average_grade
-    FROM enrolled_semesters es
-    JOIN semesters_subjects ss ON ss.enrolled_semesters_id = es.id
-    JOIN passed_subjects ps    ON ps.enrolled_id = ss.id
-    JOIN subjects s            ON s.id = ss.subjects_id
-    GROUP BY es.user_id
-),
-semester_stats AS (
-    SELECT es.user_id,
-           COUNT(es.id)        AS enrolled_semesters,
-           COUNT(es.completed) AS completed_semesters
-    FROM enrolled_semesters es
-    GROUP BY es.user_id
-),
-pending_stats AS (
-    SELECT es.user_id,
-           COUNT(ss.id) AS pending_subjects
-    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 ps.id IS NULL
-    GROUP BY es.user_id
-),
-payment_stats AS (
-    SELECT es.user_id,
-           SUM(p.amount) AS total_paid
-    FROM payment p
-    JOIN enrolled_semesters es ON es.id = p.enrollment_id
-    GROUP BY es.user_id
-),
-document_stats AS (
-    SELECT ud.user_id,
-           COUNT(ud.document_id) AS num_documents,
-           SUM(d.cost)           AS documents_cost
-    FROM user_documents ud
-    JOIN documents d ON d.id = ud.document_id
-    GROUP BY ud.user_id
-)
-SELECT u."index"                                                  AS student_index,
-       u.name || ' ' || u.surname                                 AS student,
-       COALESCE(sem.enrolled_semesters, 0)                        AS enrolled_semesters,
-       COALESCE(sem.completed_semesters, 0)                       AS completed_semesters,
-       COALESCE(ps.passed_subjects, 0)                            AS passed_subjects,
-       COALESCE(pen.pending_subjects, 0)                          AS pending_subjects,
-       COALESCE(ps.credits, 0)                                    AS credits,
-       COALESCE(CAST(ps.average_grade AS VARCHAR), 'нема оценки')  AS average_grade,
-       COALESCE(pay.total_paid, 0)                                AS total_paid,
-       COALESCE(doc.num_documents, 0)                             AS num_documents,
-       COALESCE(doc.documents_cost, 0)                            AS documents_cost
-FROM users u
-LEFT JOIN passed_stats ps    ON ps.user_id  = u.id
-LEFT JOIN semester_stats sem ON sem.user_id = u.id
-LEFT JOIN pending_stats pen  ON pen.user_id = u.id
-LEFT JOIN payment_stats pay  ON pay.user_id = u.id
-LEFT JOIN document_stats doc ON doc.user_id = u.id
-WHERE u.role = 'student'
-ORDER BY COALESCE(ps.credits, 0) DESC, COALESCE(ps.average_grade, 0) DESC, u."index";
-}}}
-
-Подредувањето намерно оди по нумеричките колони од подизразите, а не по излезните
-колони. Излезната колона average_grade е претворена во текст заради вредноста
-'нема оценки', па подредувањето по неа би било азбучно и оценката 9.00 би излегла
-пред 10.00.
-
-Плаќањата се врзуваат за студентот преку enrolled_semesters, а не преку
-payment.user_id, согласно нормализираниот модел од Фаза 5 во кој таа колона е
-отстранета.
-
-=== Релациона алгебра
-
-{{{
-PassedStats <-
-γ user_id := es.user_id;
-  passed_subjects := COUNT(ps.id),
-  credits := SUM(s.awarded_credits),
-  average_grade := AVG(ps.grade)
-(
-  (
-    (enrolled_semesters es ⨝ (es.id = ss.enrolled_semesters_id) semesters_subjects ss)
-    ⨝ (ss.id = ps.enrolled_id) passed_subjects ps
-  )
-  ⨝ (ss.subjects_id = s.id) subjects s
-)
-
-SemesterStats <-
-γ user_id := es.user_id;
-  enrolled_semesters := COUNT(es.id),
-  completed_semesters := COUNT(es.completed)
-(
-  enrolled_semesters es
-)
-
-PendingStats <-
-γ user_id := es.user_id;
-  pending_subjects := COUNT(ss.id)
-(
-  σ ps.id IS NULL
-  (
-    (enrolled_semesters es ⨝ (es.id = ss.enrolled_semesters_id) semesters_subjects ss)
-    ⟕ (ss.id = ps.enrolled_id) passed_subjects ps
-  )
-)
-
-PaymentStats <-
-γ user_id := es.user_id;
-  total_paid := SUM(p.amount)
-(
-  payment p ⨝ (p.enrollment_id = es.id) enrolled_semesters es
-)
-
-DocumentStats <-
-γ user_id := ud.user_id;
-  num_documents := COUNT(ud.document_id),
-  documents_cost := SUM(d.cost)
-(
-  user_documents ud ⨝ (ud.document_id = d.id) documents d
-)
-
-Result <-
-τ credits DESC, average_grade DESC, u.index
-(
-  π u.index,
-    student := u.name || ' ' || u.surname,
-    sem.enrolled_semesters,
-    sem.completed_semesters,
-    ps.passed_subjects,
-    pen.pending_subjects,
-    ps.credits,
-    ps.average_grade,
-    pay.total_paid,
-    doc.num_documents,
-    doc.documents_cost
-  (
-    σ u.role = 'student'
-    (
-      (
-        (
-          (
-            (
-              users u ⟕ (u.id = ps.user_id) PassedStats ps
-            )
-            ⟕ (u.id = sem.user_id) SemesterStats sem
-          )
-          ⟕ (u.id = pen.user_id) PendingStats pen
-        )
-        ⟕ (u.id = pay.user_id) PaymentStats pay
-      )
-      ⟕ (u.id = doc.user_id) DocumentStats doc
-    )
-  )
-)
-}}}
-
-=== Складирана процедура
-
-Извештајот е даден и како функција со параметар, за апликацијата со истата рутина
-да добие и едно досие и списокот на сите студенти:
-
-{{{#!sql
-CREATE OR REPLACE FUNCTION rep_student_dossier(p_user_id INTEGER DEFAULT NULL)
-    RETURNS TABLE (
-        student_index        VARCHAR,
-        student              TEXT,
-        enrolled_semesters   BIGINT,
-        completed_semesters  BIGINT,
-        passed_subjects      BIGINT,
-        pending_subjects     BIGINT,
-        credits              BIGINT,
-        average_grade        VARCHAR,
-        total_paid           BIGINT,
-        num_documents        BIGINT,
-        documents_cost       BIGINT
-    )
-    LANGUAGE sql
-    STABLE
-    SET search_path = project
-AS $$
-    -- барањето прикажано погоре, со услов:
-    -- AND (p_user_id IS NULL OR u.id = p_user_id)
-$$;
-
-SELECT * FROM rep_student_dossier();   -- сите студенти
-SELECT * FROM rep_student_dossier(1);  -- едно досие
-}}}
-
-Истиот извештај е спакуван и како вистинска процедура која отвора курсор, за
-повикувач кој ги чита редовите еден по еден наместо одеднаш:
-
-{{{#!sql
-CREATE OR REPLACE PROCEDURE rep_student_dossier_cursor(
-        IN    p_user_id INTEGER,
-        INOUT p_cursor  REFCURSOR DEFAULT 'dossier')
-    LANGUAGE plpgsql
-    SET search_path = project
-AS $$
-BEGIN
-    OPEN p_cursor FOR SELECT * FROM rep_student_dossier(p_user_id);
-END;
-$$;
-}}}
-
-Повик:
-
-{{{#!sql
-BEGIN;
-CALL rep_student_dossier_cursor(1);
-FETCH ALL FROM dossier;
-COMMIT;
-}}}
-
-Курсорот постои само во рамки на трансакцијата во која е отворен, затоа повикот е
-опкружен со BEGIN и COMMIT.
-
-=== Резултат врз тест податоците
-
-{{{
- student_index |      student       | enrolled_semesters | completed_semesters | passed_subjects | pending_subjects | credits | average_grade | total_paid | num_documents | documents_cost
----------------+--------------------+--------------------+---------------------+-----------------+------------------+---------+---------------+------------+---------------+----------------
- 233149        | Stefan Saveski     |                  2 |                   1 |               2 |                1 |      12 | 9.00          |        400 |             2 |            150
- 233188        | Boris Gjorgjievski |                  1 |                   1 |               1 |                0 |       6 | 9.00          |        400 |             1 |             50
- 233200        | Ana Petrova        |                  1 |                   0 |               1 |                1 |       6 | 7.00          |        200 |             1 |            100
-}}}
-
-== 3. Најуспешен студент по студиска програма
-
-=== Опис на извештајот
-
-За секоја студиска програма се бара студентот со најмногу собрани кредити, а при
-ист број кредити - оној со повисок просек. Ако и просекот е ист, победува
-студентот со помал идентификатор, за извештајот да враќа точно еден ред по
-програма и да дава ист резултат при секое стартување.
-
-Барањето е решено без прозорски функции, со NOT EXISTS: се задржува само оној ред
-за кој '''не постои''' подобар ред во истата програма. Истата техника е употребена
-и во извештаите 4 и 5.
-
-=== SQL
-
-{{{#!sql
-SET search_path TO project;
-
-WITH standing AS (
-    SELECT es.user_id,
-           es.major_id,
-           COUNT(ps.id)                                    AS passed_subjects,
-           COALESCE(SUM(s.awarded_credits), 0)             AS credits,
-           COALESCE(ROUND(AVG(ps.grade::TEXT::INT), 2), 0) AS average_grade
-    FROM enrolled_semesters es
-    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
-    GROUP BY es.user_id, es.major_id
-)
-SELECT m.name                       AS major,
-       u."index"                    AS student_index,
-       u.name || ' ' || u.surname   AS student,
-       st.passed_subjects,
-       st.credits,
-       st.average_grade
-FROM standing st
-JOIN users u ON u.id = st.user_id
-JOIN major m ON m.id = st.major_id
-WHERE NOT EXISTS (
-    SELECT 1
-    FROM standing st1
-    WHERE st1.major_id = st.major_id
-      AND (st.credits < st1.credits
-           OR (st.credits = st1.credits AND st.average_grade < st1.average_grade)
-           OR (st.credits = st1.credits AND st.average_grade = st1.average_grade
-               AND st.user_id > st1.user_id))
-)
-ORDER BY m.name;
-}}}
-
-Празните вредности се претворени во нула уште во подизразот standing. Ако тоа не
-се направи, споредбите со NULL даваат NULL наместо точно или неточно, па студент
-без ниту една оценка не би можел да биде ниту задржан ниту отфрлен.
-
-=== Релациона алгебра
-
-{{{
-Standing <-
-γ user_id := es.user_id,
-  major_id := es.major_id;
-  passed_subjects := COUNT(ps.id),
-  credits := SUM(s.awarded_credits),
-  average_grade := AVG(ps.grade)
-(
-  (
-    (enrolled_semesters es ⟕ (es.id = ss.enrolled_semesters_id) semesters_subjects ss)
-    ⟕ (ss.id = ps.enrolled_id) passed_subjects ps
-  )
-  ⟕ (ss.subjects_id = s.id ∧ ps.id IS NOT NULL) subjects s
-)
-
-Dominated <-
-π attributes(st)
-(
-  σ st.major_id = st1.major_id ∧
-    (
-      st.credits < st1.credits
-      ∨ (st.credits = st1.credits ∧ st.average_grade < st1.average_grade)
-      ∨ (st.credits = st1.credits ∧ st.average_grade = st1.average_grade ∧
-         st.user_id > st1.user_id)
-    )
-  (
-    ρ st(Standing) × ρ st1(Standing)
-  )
-)
-
-TopStudents <- Standing − Dominated
-
-Result <-
-τ m.name
-(
-  π m.name,
-    u.index,
-    student := u.name || ' ' || u.surname,
-    st.passed_subjects,
-    st.credits,
-    st.average_grade
-  (
-    (
-      TopStudents st ⨝ (st.user_id = u.id) users u
-    )
-    ⨝ (st.major_id = m.id) major m
-  )
-)
-}}}
-
-Условот NOT EXISTS во релациона алгебра се изразува со разлика: од сите редови се
-одземаат оние за кои постои подобар ред во истата група.
-
-=== Складирана процедура
-
-{{{#!sql
-CREATE OR REPLACE FUNCTION rep_top_student_per_major()
-    RETURNS TABLE (
-        major            VARCHAR,
-        student_index    VARCHAR,
-        student          TEXT,
-        passed_subjects  BIGINT,
-        credits          BIGINT,
-        average_grade    NUMERIC
-    )
-    LANGUAGE sql
-    STABLE
-    SET search_path = project
-AS $$
-    -- барањето прикажано погоре
-$$;
-
-SELECT * FROM rep_top_student_per_major();
-}}}
-
-=== Резултат врз тест податоците
-
-{{{
-                    major                     | student_index |    student     | passed_subjects | credits | average_grade
-----------------------------------------------+---------------+----------------+-----------------+---------+---------------
- Computer Science and Engineering             | 233200        | Ana Petrova    |               1 |       6 |          7.00
- Software Engineering and Information Systems | 233149        | Stefan Saveski |               2 |      12 |          9.00
-}}}
-
-== 4. Најоптоварен професор по активен семестар
-
-=== Опис на извештајот
-
-За секој активен семестар се бара професорот кај кого се запишани најмногу
-студенти, а при ист број - оној кој оценил повеќе. Извештајот тргнува од табелата
-active_semesters, па во него се појавуваат и семестрите во кои сè уште никој не се
-запишал; за нив се прикажува 'n/a' и нули. Тоа е список кој администраторот го
-користи за да види каде распоредот сè уште не е пополнет.
-
-=== SQL
-
-{{{#!sql
-SET search_path TO project;
-
-WITH professor_load AS (
-    SELECT es.semester_id,
-           ss.professor_id,
-           COUNT(ss.id)                   AS enrolled_students,
-           COUNT(ps.id)                   AS graded_students,
-           COUNT(DISTINCT ss.subjects_id) AS subjects_taught
-    FROM semesters_subjects ss
-    JOIN enrolled_semesters es   ON es.id = ss.enrolled_semesters_id
-    LEFT JOIN passed_subjects ps ON ps.enrolled_id = ss.id
-    GROUP BY es.semester_id, ss.professor_id
-),
-busiest AS (
-    SELECT pl.*
-    FROM professor_load pl
-    WHERE NOT EXISTS (
-        SELECT 1
-        FROM professor_load pl1
-        WHERE pl1.semester_id = pl.semester_id
-          AND (pl.enrolled_students < pl1.enrolled_students
-               OR (pl.enrolled_students = pl1.enrolled_students
-                   AND pl.graded_students < pl1.graded_students)
-               OR (pl.enrolled_students = pl1.enrolled_students
-                   AND pl.graded_students = pl1.graded_students
-                   AND pl.professor_id > pl1.professor_id))
-    )
-)
-SELECT a.year,
-       a.type                                      AS semester_type,
-       COALESCE(u.name || ' ' || u.surname, 'n/a') AS professor,
-       COALESCE(b.subjects_taught, 0)              AS subjects_taught,
-       COALESCE(b.enrolled_students, 0)            AS enrolled_students,
-       COALESCE(b.graded_students, 0)              AS graded_students
-FROM active_semesters a
-LEFT JOIN busiest b ON b.semester_id = a.id
-LEFT JOIN users u   ON u.id = b.professor_id
-ORDER BY a.year, CASE a.type WHEN 'summer' THEN 1 ELSE 2 END;
-}}}
-
-Подредувањето не оди по името на семестарот, туку по година и по редоследот во
-академската година - летниот семестар доаѓа пред зимскиот од истата година.
-
-=== Релациона алгебра
-
-{{{
-ProfessorLoad <-
-γ semester_id := es.semester_id,
-  professor_id := ss.professor_id;
-  enrolled_students := COUNT(ss.id),
-  graded_students := COUNT(ps.id),
-  subjects_taught := COUNT(DISTINCT ss.subjects_id)
-(
-  (
-    semesters_subjects ss
-    ⨝ (ss.enrolled_semesters_id = es.id) enrolled_semesters es
-  )
-  ⟕ (ss.id = ps.enrolled_id) passed_subjects ps
-)
-
-Dominated <-
-π attributes(pl)
-(
-  σ pl.semester_id = pl1.semester_id ∧
-    (
-      pl.enrolled_students < pl1.enrolled_students
-      ∨ (pl.enrolled_students = pl1.enrolled_students ∧
-         pl.graded_students < pl1.graded_students)
-      ∨ (pl.enrolled_students = pl1.enrolled_students ∧
-         pl.graded_students = pl1.graded_students ∧
-         pl.professor_id > pl1.professor_id)
-    )
-  (
-    ρ pl(ProfessorLoad) × ρ pl1(ProfessorLoad)
-  )
-)
-
-Busiest <- ProfessorLoad − Dominated
-
-Result <-
-τ a.year, (a.type = 'summer' ? 1 : 2)
-(
-  π a.year,
-    a.type,
-    professor := u.name || ' ' || u.surname,
-    b.subjects_taught,
-    b.enrolled_students,
-    b.graded_students
-  (
-    (
-      active_semesters a ⟕ (a.id = b.semester_id) Busiest b
-    )
-    ⟕ (b.professor_id = u.id) users u
-  )
-)
-}}}
-
-=== Складирана процедура
-
-{{{#!sql
-CREATE OR REPLACE FUNCTION rep_busiest_professor()
-    RETURNS TABLE (
-        year               INTEGER,
-        semester_type      semester_type,
-        professor          TEXT,
-        subjects_taught    BIGINT,
-        enrolled_students  BIGINT,
-        graded_students    BIGINT
-    )
-    LANGUAGE sql
-    STABLE
-    SET search_path = project
-AS $$
-    -- барањето прикажано погоре
-$$;
-
-SELECT * FROM rep_busiest_professor();
-}}}
-
-=== Резултат врз тест податоците
-
-{{{
- year | semester_type |    professor     | subjects_taught | enrolled_students | graded_students
-------+---------------+------------------+-----------------+-------------------+-----------------
- 2024 | winter        | Vangel Ajanovski |               1 |                 3 |               3
- 2025 | summer        | Vangel Ajanovski |               1 |                 1 |               0
- 2025 | winter        | n/a              |               0 |                 0 |               0
- 2026 | summer        | n/a              |               0 |                 0 |               0
- 2026 | winter        | n/a              |               0 |                 0 |               0
-}}}
-
-== 5. Процентуална промена на активноста меѓу два последователни семестри
-
-=== Опис на извештајот
-
-За секој активен семестар се прикажува колку запишувања, колку запишани предмети и
-колку положени предмети имало, и колку тоа отстапува од претходниот семестар,
-изразено во проценти. Ова е извештајот со кој се следи дали бројот на запишувања
-расте или опаѓа.
-
-Семестрите немаат колона со редослед - тие се пар од година и тип. Затоа прво се
-пресметува хронолошки клуч, а потоа за секој семестар се бара претходниот како оној
-пред него за кој '''не постои''' семестар помеѓу нив.
-
-=== SQL
-
-{{{#!sql
-SET search_path TO project;
-
-WITH ordered_semesters AS (
-    SELECT a.id,
-           a.year,
-           a.type,
-           a.year * 10 + CASE a.type WHEN 'summer' THEN 1 ELSE 2 END AS chrono
-    FROM active_semesters a
-),
-semester_stats AS (
-    SELECT o.id                  AS semester_id,
-           o.year,
-           o.type,
-           o.chrono,
-           COUNT(DISTINCT es.id) AS enrolments,
-           COUNT(ss.id)          AS subject_enrolments,
-           COUNT(ps.id)          AS passed
-    FROM ordered_semesters o
-    LEFT JOIN enrolled_semesters es ON es.semester_id = o.id
-    LEFT JOIN semesters_subjects ss ON ss.enrolled_semesters_id = es.id
-    LEFT JOIN passed_subjects ps    ON ps.enrolled_id = ss.id
-    GROUP BY o.id, o.year, o.type, o.chrono
-),
-consecutive AS (
-    SELECT cur.year, cur.type, cur.chrono,
-           cur.enrolments, cur.subject_enrolments, cur.passed,
-           prev.year               AS prev_year,
-           prev.type               AS prev_type,
-           prev.subject_enrolments AS prev_subject_enrolments
-    FROM semester_stats cur
-    LEFT JOIN semester_stats prev
-           ON prev.chrono < cur.chrono
-          AND NOT EXISTS (SELECT 1
-                          FROM semester_stats mid
-                          WHERE mid.chrono < cur.chrono
-                            AND mid.chrono > prev.chrono)
-)
-SELECT c.year || '-' || c.type                                       AS semester,
-       COALESCE(c.prev_year || '-' || c.prev_type, 'нема претходен')  AS previous_semester,
-       c.enrolments,
-       c.subject_enrolments,
-       c.passed,
-       COALESCE(CAST(c.prev_subject_enrolments AS VARCHAR), 'n/a')   AS prev_subject_enrolments,
-       COALESCE(CAST(ROUND(((c.subject_enrolments - c.prev_subject_enrolments) * 100.0)
-                           / NULLIF(c.prev_subject_enrolments, 0)) AS VARCHAR) || '%',
-                'n/a')                                              AS pct_change
-FROM consecutive c
-ORDER BY c.chrono;
-}}}
-
-Процентот се спојува со знакот '%' преку операторот {{{||}}}, а не преку CONCAT.
-CONCAT ги игнорира празните вредности и за семестар без претходник би вратил само
-'%', па COALESCE никогаш не би се активирал. Операторот {{{||}}} враќа NULL ако
-некој од операндите е NULL, што е токму она што му треба на COALESCE за да испише
-'n/a'.
-
-Делењето е заштитено со NULLIF, за семестар во кој претходно немало ниту еден
-запишан предмет да не предизвика делење со нула.
-
-=== Релациона алгебра
-
-{{{
-OrderedSemesters <-
-π a.id,
-  a.year,
-  a.type,
-  chrono := a.year * 10 + (a.type = 'summer' ? 1 : 2)
-(
-  active_semesters a
-)
-
-SemesterStats <-
-γ semester_id := o.id,
-  year := o.year,
-  type := o.type,
-  chrono := o.chrono;
-  enrolments := COUNT(DISTINCT es.id),
-  subject_enrolments := COUNT(ss.id),
-  passed := COUNT(ps.id)
-(
-  (
-    (
-      OrderedSemesters o ⟕ (o.id = es.semester_id) enrolled_semesters es
-    )
-    ⟕ (es.id = ss.enrolled_semesters_id) semesters_subjects ss
-  )
-  ⟕ (ss.id = ps.enrolled_id) passed_subjects ps
-)
-
-Earlier <-
-σ prev.chrono < cur.chrono
-(
-  ρ cur(SemesterStats) × ρ prev(SemesterStats)
-)
-
-NotImmediate <-
-π attributes(Earlier)
-(
-  σ mid.chrono < cur.chrono ∧ mid.chrono > prev.chrono
-  (
-    Earlier × ρ mid(SemesterStats)
-  )
-)
-
-Immediate <- Earlier − NotImmediate
-
-Consecutive <-
-SemesterStats cur ⟕ (cur.chrono = imm.cur_chrono) Immediate imm
-
-Result <-
-τ c.chrono
-(
-  π semester := c.year || '-' || c.type,
-    previous_semester := c.prev_year || '-' || c.prev_type,
-    c.enrolments,
-    c.subject_enrolments,
-    c.passed,
-    c.prev_subject_enrolments,
-    pct_change := ((c.subject_enrolments − c.prev_subject_enrolments) * 100)
-                  / c.prev_subject_enrolments
-  (
-    Consecutive c
-  )
-)
-}}}
-
-Релацијата Immediate ги содржи само паровите (семестар, претходен семестар) меѓу
-кои нема трет семестар. Тоа е истата разлика како во извештаите 3 и 4: од сите
-порани парови се одземаат оние за кои постои семестар помеѓу.
-
-=== Складирана процедура
-
-{{{#!sql
-CREATE OR REPLACE FUNCTION rep_semester_growth()
-    RETURNS TABLE (
-        semester                 TEXT,
-        previous_semester        TEXT,
-        enrolments               BIGINT,
-        subject_enrolments       BIGINT,
-        passed                   BIGINT,
-        prev_subject_enrolments  VARCHAR,
-        pct_change               VARCHAR
-    )
-    LANGUAGE sql
-    STABLE
-    SET search_path = project
-AS $$
-    -- барањето прикажано погоре
-$$;
-
-SELECT * FROM rep_semester_growth();
-}}}
-
-=== Резултат врз тест податоците
-
-{{{
-  semester   | previous_semester | enrolments | subject_enrolments | passed | prev_subject_enrolments | pct_change
--------------+-------------------+------------+--------------------+--------+-------------------------+------------
- 2024-winter | нема претходен    |          3 |                  5 |      4 | n/a                     | n/a
- 2025-summer | 2024-winter       |          1 |                  1 |      0 | 5                       | -80%
- 2025-winter | 2025-summer       |          0 |                  0 |      0 | 1                       | -100%
- 2026-summer | 2025-winter       |          0 |                  0 |      0 | 0                       | n/a
- 2026-winter | 2026-summer       |          0 |                  0 |      0 | 0                       | n/a
-}}}
-
-== Заеднички забелешки за имплементацијата
-
- * '''Еден ред по група без прозорски функции.''' Извештаите 3, 4 и 5 бараат „најдобриот во групата“ односно „претходниот по ред“. Наместо ROW_NUMBER, употребен е NOT EXISTS, кој во релациона алгебра директно се пресликува во разлика на две релации. Условот за израмнување е секогаш строг и завршува со споредба на идентификатор, па извештајот враќа точно еден ред по група и е повторлив.
- * '''Надворешни спојувања наместо внатрешни.''' Предмет кој никој не го положил, семестар во кој никој не се запишал и студент кој нема платено сепак се појавуваат во извештаите. Внатрешно спојување би ги сокрило токму редовите кои се најинтересни за администраторот.
- * '''Претворање на оценката.''' grade е од набројувачки тип, па {{{AVG(grade)}}} не е дозволено. Секаде е употребено {{{ps.grade::TEXT::INT}}}.
- * '''Празни вредности.''' Сите бројачи излегуваат преку COALESCE како нула, а просеците како текстот 'нема оценки'. Делењата се заштитени со NULLIF.
- * '''Патека на шемата.''' Секоја рутина носи {{{SET search_path = project}}}, па работи исто без оглед на тоа што повикувачот поставил.
-
-== Историјат
-
- '''Верзија 1''' - Прва верзија: пет извештаи, секој со SQL, релациона алгебра и
- складирана рутина. Сите барања се тестирани врз шемата project со податоците од
- data_load.sql.
-
-== Статус
-
- ''' [[span(style=color: #FF8000, Во тек )]] '''
Index: docs/Ph8.md
===================================================================
--- docs/Ph8.md	(revision 002cf5f9fe36565e030e882d63222c760d638995)
+++ docs/Ph8.md	(revision 002cf5f9fe36565e030e882d63222c760d638995)
@@ -0,0 +1,446 @@
+== Напреден апликативен развој
+
+Backend делот од апликацијата е .NET 9 Web API кој до базата пристапува преку
+Entity Framework Core и драјверот Npgsql
+([https://www.nuget.org/packages/Npgsql.EntityFrameworkCore.PostgreSQL Npgsql.EntityFrameworkCore.PostgreSQL 9.0.4]).
+Базата е зад SSH тунел, па секоја физичка конекција е уште еден канал низ тунелот -
+тоа е причината зошто во оваа фаза и трансакциите и pool-от се разгледуваат заедно.
+
+Оваа фаза опфаќа:
+
+ * '''Трансакции''' - кои сценарија пишуваат во повеќе табели и како тие се водат како една целина
+ * '''Ниво на изолација''' - што се случува кога два студента запишуваат ист семестар истовремено
+ * '''Pooling''' - како се менаџираат конекциите и колку од нив навистина се отвораат
+
+== 1. Трансакции
+
+=== Како EF Core води трансакција
+
+EF Core не бара анотација како {{{@Transactional}}}. Секој повик на
+{{{SaveChangesAsync()}}} сам по себе е една трансакција - сите промени натрупани во
+контекстот се испраќаат во еден BEGIN/COMMIT. Затоа операција која пишува во повеќе
+табели '''еднаш''' не бара ништо дополнително.
+
+Експлицитна трансакција е потребна кога операцијата мора да повика
+{{{SaveChangesAsync()}}} '''повеќе пати''', најчесто затоа што вториот запис го
+употребува идентификаторот доделен при првиот. Тогаш двата повика се затвораат во
+{{{BeginTransactionAsync()}}} ... {{{CommitAsync()}}}, за да не остане запис од
+првиот чекор ако вториот падне.
+
+Во апликацијата тоа се три сценарија: регистрација на студент, запишување на
+семестар и бришење на предмет.
+
+=== 1.1 Регистрација на студент (UC002)
+
+Корисникот, неговите контакт податоци и средношколските податоци се три табели.
+Contact и high_school имаат надворешен клуч кон users, па идентификаторот на
+корисникот мора прво да постои - оттука и двата повика на {{{SaveChangesAsync}}}.
+
+{{{#!csharp
+public async Task<bool> RegisterAsync(RegisterDto registerDto)
+{
+    if (await _userRepository.UserExistsAsync(registerDto.Email))
+        throw new InvalidOperationException("User with this email already exists");
+
+    // Use a transaction to ensure all entities are saved together
+    using var transaction = await _context.Database.BeginTransactionAsync();
+    try
+    {
+        // quota and enrollment_year are attributes of Users in the ER
+        // model, so they are set here rather than on a separate entity.
+        var user = new User
+        {
+            Name = registerDto.Name,
+            Surname = registerDto.Surname,
+            Index = registerDto.Index,
+            Email = registerDto.Email,
+            PasswordHash = BCrypt.Net.BCrypt.HashPassword(registerDto.Password),
+            Bday = DateTime.SpecifyKind(registerDto.Bday, DateTimeKind.Unspecified),
+            CreatedAt = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified),
+            Role = (Models.UserRole)registerDto.Role,
+            EMBG = registerDto.EMBG,
+            Quota = (Models.Quota)registerDto.quotaType,
+            EnrollmentYear = registerDto.enrollmentYear
+        };
+
+        _context.User.Add(user);
+        await _context.SaveChangesAsync();   // <- тука user.Id добива вредност
+
+        int userId = user.Id;
+
+        var contactInfo = new ContactInfo
+        {
+            UserId = userId,
+            City = registerDto.city,
+            Address = registerDto.address,
+            Municipality = registerDto.municipality,
+            PhoneNumber = registerDto.phoneNumber,
+            MicrosoftEmail = registerDto.microsoftEmail
+        };
+
+        var highSchool = new HighSchool
+        {
+            UserId = userId,
+            GPA = registerDto.gpa,
+            HighSchoolType = (Models.HighSchoolType)registerDto.tip
+        };
+
+        _context.ContactInfo.Add(contactInfo);
+        _context.HighSchool.Add(highSchool);
+
+        // Save all related entities in a single transaction
+        await _context.SaveChangesAsync();
+        await transaction.CommitAsync();
+
+        return true;
+    }
+    catch
+    {
+        await transaction.RollbackAsync();
+        throw;
+    }
+}
+}}}
+
+Без трансакцијата, пад при вториот {{{SaveChangesAsync}}} би оставил корисник без
+контакт и без средношколски податоци - запис кој ниту може да се употреби, ниту
+може да се регистрира повторно, бидејќи е-поштата е UNIQUE.
+
+=== 1.2 Запишување на семестар (UC007)
+
+Запишувањето создава еден ред во enrolled_semesters и '''точно пет''' реда во
+semesters_subjects. Редовите за предметите го бараат идентификаторот на
+запишувањето, па повторно се работи за два чекора.
+
+{{{#!csharp
+await using var transaction = await _context.Database.BeginTransactionAsync();
+try
+{
+    var enrolment = new EnrolledSemesters
+    {
+        UserId = studentId,
+        SemesterId = request.SemesterId,
+        MajorId = request.MajorId,
+        // enrolled_semesters.quota is NOT NULL; fall back to the
+        // state quota when the student record has none.
+        QuotaType = student.Quota ?? Models.Quota.drzavna,
+        CratedAt = now,
+        LastChange = now,
+        Verified = null
+    };
+
+    _context.EnrolledSemesters.Add(enrolment);
+    await _context.SaveChangesAsync();
+
+    foreach (var subjectId in subjectIds)
+    {
+        _context.SemesterSubjects.Add(new SemesterSubject
+        {
+            EnrolledSemesterId = enrolment.Id,
+            SubjectId = subjectId,
+            ProfessorId = professorBySubject[subjectId],
+            Signature = false
+        });
+    }
+
+    await _context.SaveChangesAsync();
+    await transaction.CommitAsync();
+
+    return new EnrollSemesterResultDto
+    {
+        Ok = true,
+        Message = $"Enrolled with {subjectIds.Count} subjects.",
+        EnrolledSemesterId = enrolment.Id
+    };
+}
+}}}
+
+Овде трансакцијата не е само заштита од полузапишан семестар. Ограничувањето за
+големина на запишувањето од Фаза 7 е '''одложен''' тригер:
+
+{{{#!sql
+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();
+}}}
+
+Одложен тригер се проверува при COMMIT, а не по секој ред. Токму затоа петте
+предмети се внесуваат во иста трансакција: ако се внесуваа секој во своја
+трансакција, првиот ред ќе беше видлив во база како запишување со еден предмет, а
+правилото „најмногу 5 предмети и најмногу 30 кредити“ ќе се проверуваше пет пати
+наместо еднаш, на крајот.
+
+=== 1.3 Бришење на предмет (UC012)
+
+Предметот е референциран од три врзни табели. Тие мора да се избришат пред него, а
+ако некоја од нив падне, предметот мора да остане недопрен.
+
+{{{#!csharp
+// semesters_subjects references subjects, so a taken subject cannot go.
+var taken = await _context.SemesterSubjects.CountAsync(ss => ss.SubjectId == subjectId);
+if (taken > 0)
+{
+    return Fail($"Cannot delete: {taken} enrolment(s) already contain this subject.");
+}
+
+await using var transaction = await _context.Database.BeginTransactionAsync();
+try
+{
+    // The mapping rows must go first; their foreign keys would block the delete.
+    var prereqs = await _context.DependencySubjects
+        .Where(d => d.SubjectId == subjectId || d.DependencyId == subjectId)
+        .ToListAsync();
+    _context.DependencySubjects.RemoveRange(prereqs);
+
+    var majors = await _context.MajorSubjects
+        .Where(ms => ms.SubjectId == subjectId).ToListAsync();
+    _context.MajorSubjects.RemoveRange(majors);
+
+    var teaching = await _context.ProfessorSubjects
+        .Where(ps => ps.SubjectId == subjectId).ToListAsync();
+    _context.ProfessorSubjects.RemoveRange(teaching);
+
+    _context.Subjects.Remove(subject);
+    await _context.SaveChangesAsync();
+    await transaction.CommitAsync();
+
+    return Ok($"Subject '{subject.Name}' deleted.");
+}
+catch
+{
+    await transaction.RollbackAsync();
+    throw;
+}
+}}}
+
+=== 1.4 Оценување (UC010) - кога трансакција не е потребна
+
+Внесувањето на оценка пишува во една табела, со еден
+{{{SaveChangesAsync}}}, па EF Core сам го обвиткува во трансакција. Додавање на
+експлицитна трансакција овде не би променило ништо, освен уште една размена со
+серверот.
+
+{{{#!csharp
+case GradeAction.Add:
+    if (existing is not null)
+    {
+        return Fail("This subject is already graded. Use edit to change the grade.");
+    }
+    await _profRepository.AddPassedSubjectAsync(new PassedSubject
+    {
+        SemesterSubjectId = semesterSubject.Id,
+        Grade = (Grade)request.Grade,
+        // date_passed is `timestamp without time zone`.
+        DatePassed = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)
+    });
+    return Ok($"Grade {request.Grade} added.");
+}}}
+
+Вреди да се забележи дека и овде има повеќе од еден запис во базата, но вториот го
+прави '''базата''', не апликацијата: тригерот trg_grade_rules од Фаза 7 го
+поставува потписот и создава известување за студентот. Бидејќи тие се извршуваат во
+истата трансакција како и внесувањето, оценка без потпис и без известување не може
+да постои.
+
+=== 1.5 Ниво на изолација и конкурентни запишувања
+
+Ниту EF Core ниту Npgsql не го менуваат нивото на изолација, па важи
+стандардното ниво на PostgreSQL - '''READ COMMITTED'''. Тоа значи дека секое
+барање во трансакцијата ја гледа состојбата потврдена пред тоа барање, но не ги
+гледа незавршените трансакции на другите.
+
+Тоа е доволно за сите сценарија освен за едно. Пред да запише, апликацијата
+проверува дали студентот веќе го запишал тој семестар:
+
+{{{#!csharp
+// enrolled_semesters has UNIQUE (user_id, semester_id); check first so
+// the student gets a sentence rather than a constraint violation.
+if (await _context.EnrolledSemesters.AnyAsync(
+        es => es.UserId == studentId && es.SemesterId == request.SemesterId))
+{
+    return Fail("You are already enrolled in that semester.");
+}
+}}}
+
+Ако студентот два пати кликне на копчето, двете барања ја прават оваа проверка пред
+било кое од нив да потврди, па двете ја поминуваат. Проверката во апликација не
+може да го спречи тоа - таа чита состојба која во меѓувреме се менува.
+
+Ограничувањето во базата може. Проверено со две истовремени трансакции врз шемата
+project:
+
+{{{
+-- сесија А                          -- сесија Б (0.5s подоцна)
+BEGIN;                               BEGIN;
+INSERT INTO enrolled_semesters       INSERT INTO enrolled_semesters
+  (user_id, quota, major_id,           (user_id, quota, major_id,
+   semester_id)                         semester_id)
+VALUES (1, 'drzavna', 1, 3);         VALUES (1, 'drzavna', 1, 3);
+SELECT pg_sleep(2);                  -- чека А да заврши
+COMMIT;                              ERROR: duplicate key value violates unique
+                                     constraint "enrolled_semesters_user_id_semester_id_key"
+                                     DETAIL: Key (user_id, semester_id)=(1, 3) already exists.
+                                     ROLLBACK
+}}}
+
+Втората трансакција не добива грешка веднаш - таа '''чека''' на редот вметнат од
+првата, бидејќи PostgreSQL мора прво да види дали првата ќе потврди или ќе се
+врати. Дури по COMMIT-от на првата, втората паѓа.
+
+Апликацијата затоа ја фаќа таа грешка и ја претвора во истата реченица која ја враќа
+и проверката погоре, наместо студентот да добие 500:
+
+{{{#!csharp
+catch (DbUpdateException ex) when (IsUniqueViolation(ex))
+{
+    // Two enrolments for the same semester sent at the same time both
+    // pass the check above, because each transaction reads the state
+    // from before the other one wrote. UNIQUE (user_id, semester_id)
+    // is what actually settles it, so the loser gets the same
+    // sentence as if the check had caught it.
+    await transaction.RollbackAsync();
+    return Fail("You are already enrolled in that semester.");
+}
+catch
+{
+    await transaction.RollbackAsync();
+    throw;
+}
+
+/// <summary>23505 is unique_violation.</summary>
+private static bool IsUniqueViolation(DbUpdateException ex) =>
+    ex.InnerException is PostgresException { SqlState: "23505" };
+}}}
+
+Значи проверката во апликацијата постои заради пораката, а ограничувањето во базата
+заради точноста. Истиот принцип важи и за правилата од Фаза 7: апликацијата ги
+проверува за да даде разбирлива порака, а базата ги чува за да важат и кога некој
+пишува директно во неа.
+
+== 2. Pooling
+
+=== 2.1 Pool на конекции (Npgsql)
+
+Како и кај Spring Boot, конекциите не се отвораат рачно. Npgsql има вграден pool
+кој е вклучен стандардно, па {{{UseNpgsql(connectionString)}}} е доволно.
+
+Стандардните вредности на Npgsql 9.0.4 (отчитани од
+{{{NpgsqlConnectionStringBuilder}}}):
+
+{{{
+Pooling                     = True
+Minimum Pool Size           = 0
+Maximum Pool Size           = 100
+Connection Idle Lifetime    = 300     (секунди)
+Connection Pruning Interval = 10      (секунди)
+Timeout                     = 15      (секунди, чекање на слободна конекција)
+Command Timeout             = 30      (секунди)
+Multiplexing                = False
+}}}
+
+Стандардните 100 конекции се премногу за оваа поставеност: серверот е споделен меѓу
+сите проекти, а секоја конекција е уште еден канал низ SSH тунелот. Затоа pool-от е
+ограничен во самиот connection string:
+
+{{{#!json
+"ConnectionStrings": {
+  "DefaultConnection": "Host=localhost;Port=9999;Database=db_202526z_va_prj_know2026;Username=db_202526z_va_prj_iknow2026_owner;Password=REDACTED_ROTATE_ME;Search Path=project;Include Error Detail=true;Maximum Pool Size=10;Minimum Pool Size=1;Connection Idle Lifetime=300;Timeout=15"
+}
+}}}
+
+ * '''Maximum Pool Size=10''' - горна граница на отворени конекции, исто како default-от на HikariCP
+ * '''Minimum Pool Size=1''' - една конекција останува отворена, за тунелот да не мора да отвора нов канал за секое прво барање
+ * '''Connection Idle Lifetime=300''' - неупотребените конекции над минимумот се затвораат по 5 минути
+ * '''Timeout=15''' - ако сите 10 се зафатени, барањето чека најмногу 15 секунди пред да падне
+
+=== 2.2 Pool на DbContext (EF Core)
+
+Покрај конекциите, EF Core може да ги рециклира и самите DbContext објекти. Тоа е
+одделен pool: {{{AddDbContextPool}}} ги чува инстанците на контекстот (со нивните
+поставки и мапирања), додека Npgsql ги чува физичките конекции под нив.
+
+{{{#!csharp
+// AddDbContextPool reuses the DbContext instances themselves; the connections
+// underneath them are pooled separately by Npgsql, sized in the connection
+// string. Both matter here because every physical connection is one more
+// channel through the SSH tunnel.
+builder.Services.AddDbContextPool<AppDbContext>(options =>
+    options.UseNpgsql(
+        builder.Configuration.GetConnectionString("DefaultConnection"),
+        npgsql =>
+        {
+            npgsql.MapEnum<UserRole>("user_role", AppDbContext.ProjectSchema, PgEnumLabels.UserRole);
+            npgsql.MapEnum<HighSchoolType>("hs_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase);
+            npgsql.MapEnum<Quota>("quota_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase);
+            npgsql.MapEnum<sType>("semester_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase);
+            npgsql.MapEnum<Grade>("grade_type", AppDbContext.ProjectSchema, PgEnumLabels.Grade);
+        }));
+}}}
+
+Ова е применливо бидејќи AppDbContext има само еден конструктор, оној кој прима
+{{{DbContextOptions}}}. Контекст кој би примал сопствени зависности (на пример
+тековниот корисник) не смее да се рециклира, бидејќи состојбата од едно барање би
+протекла во следното.
+
+=== 2.3 Мерење на pool-от
+
+Мерено со апликацијата пуштена врз локална база со истата шема, со броење на
+{{{pg_stat_activity}}}:
+
+{{{#!sql
+SELECT count(*) FROM pg_stat_activity WHERE datname = 'iknow';
+}}}
+
+{{{
+состојба                                          отворени конекции
+--------------------------------------------------------------------
+по стартување и едно барање                              1
+за време на 50 истовремени барања                       10
+по завршување на барањата                               10
+}}}
+
+Првиот ред е Minimum Pool Size=1 - една конекција останува отворена. Вториот ред
+покажува дека pool-от расте со оптоварувањето, но застанува точно на Maximum Pool
+Size=10; останатите барања чекаат слободна конекција наместо да отворат нова.
+Третиот ред покажува дека отворените конекции не се затвораат веднаш - тие остануваат
+во pool-от и се затвораат дури по Connection Idle Lifetime, бидејќи повторното
+отворање низ тунел е поскапо од држењето отворена конекција.
+
+Истото може да се провери и од страната на тунелот - секоја нова конекција се гледа
+како нов канал во логовите на тунел скриптата:
+
+{{{
+debug1: Connection to port 9999 forwarding to localhost port 5432 requested.
+debug1: channel 2: new direct-tcpip [direct-tcpip] (inactive timeout: 0)
+}}}
+
+=== 2.4 Проверка дека апликацијата е поврзана
+
+Контролерот DbHealth го враќа одговорот кој покажува дека тунелот, конекцијата и
+мапирањето на ентитетите работат:
+
+{{{#!json
+{
+  "connected": true,
+  "server": "PostgreSQL 18.4 ... on x86_64-pc-linux-gnu, 64-bit",
+  "counts": {
+    "users": 8,
+    "subjects": 20,
+    "enrolledSemesters": 4,
+    "semesterSubjects": 6,
+    "passedSubjects": 4
+  }
+}
+}}}
+
+== Историјат
+
+ '''Верзија 1''' - Прва верзија: трансакции во трите сценарија кои пишуваат во
+ повеќе табели, обработка на конкурентни запишувања преку ограничувањето
+ UNIQUE (user_id, semester_id), и конфигурација и мерење на pool-от на конекции и
+ на DbContext.
+
+== Статус
+
+ ''' [[span(style=color: #FF8000, Во тек )]] '''
