Changes between Initial Version and Version 1 of OtherTopics


Ignore:
Timestamp:
09/04/26 11:37:28 (6 days ago)
Author:
236021
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • OtherTopics

    v1 v1  
     1= Other Topics =
     2
     3== SQL Performance ==
     4
     5=== Scenario 1: Appointments Table Filtering ===
     6
     7This scenario tests a '''single-table range filter''' on a large table with ~50% selectivity, where an index on status and date can significantly improve scan performance over a sequential scan.
     8
     9Making appointments table have 1 million rows:
     10
     11{{{
     12WITH patient_ids AS (
     13    SELECT array_agg(patient_id) AS ids FROM patients
     14),
     15doctor_ids AS (
     16    SELECT array_agg(doctor_id) AS ids FROM doctors
     17),
     18base AS (
     19    SELECT
     20        gs,
     21        CURRENT_DATE + ((random() * 240 - 120)::INT) AS gen_date
     22    FROM generate_series(1, 1000000) gs
     23)
     24INSERT INTO appointments (appointment_id, appointment_date, appointment_time, status, patient_id, doctor_id)
     25SELECT
     26    (SELECT COALESCE(MAX(appointment_id), 0) FROM appointments) + gs,
     27    gen_date,
     28    make_time((floor(random() * 24))::INT, (floor(random() * 60))::INT, 0)::TIME,
     29    CASE
     30        WHEN gen_date < CURRENT_DATE THEN
     31            (ARRAY['COMPLETED', 'CANCELLED', 'IN_PROGRESS'])[floor(random()*3)+1]
     32        ELSE
     33            'SCHEDULED'
     34        END,
     35    (SELECT ids[floor(random()*array_length(ids,1))+1] FROM patient_ids),
     36    (SELECT ids[floor(random()*array_length(ids,1))+1] FROM doctor_ids)
     37FROM base;
     38}}}
     39
     40I disabled the `trigger_appointments_no_overlap` and `trigger_appointments_enforce` triggers for easier bulk testing (the overlap and past-date checks would otherwise reject most of the synthetic rows), and re-enabled both immediately after the insert completed.
     41
     42Final data was verified to have realistic ~50% selectivity for the target query before benchmarking:
     43
     44{{{
     45total = 1,000,014
     46scheduled = 502,677
     47future_dated = 502,671
     48matching filter (status = 'SCHEDULED' AND appointment_date >= CURRENT_DATE) = 502,671
     49}}}
     50
     51Executing the query 10 times plus 1 discarded warm-up run to see average execution time:
     52
     53{{{
     54EXPLAIN (ANALYZE, BUFFERS)
     55SELECT appointment_id, appointment_date, appointment_time, status, patient_id, doctor_id
     56FROM appointments
     57WHERE status = 'SCHEDULED'
     58  AND appointment_date >= CURRENT_DATE;
     59}}}
     60
     61After the first recorded execution:
     62
     63{{{
     64Seq Scan on appointments  (cost=0.00..41790.90 rows=302562 width=46) (actual time=0.012..171.250 rows=502671.00 loops=1)
     65  Filter: (((status)::text = 'SCHEDULED'::text) AND (appointment_date >= CURRENT_DATE))
     66  Rows Removed by Filter: 497343
     67  Buffers: shared hit=11270 read=9349
     68Planning Time: 0.068 ms
     69Execution Time: 192.175 ms
     70}}}
     71
     72'''Average execution time WITHOUT index''': 177.60ms
     73
     74Created the index:
     75
     76{{{
     77CREATE INDEX idx_appointments_status_date
     78    ON appointments (status, appointment_date DESC);
     79}}}
     80
     81After the first recorded execution with the index present:
     82
     83{{{
     84Bitmap Heap Scan on appointments  (cost=3455.86..28451.45 rows=250091 width=46) (actual time=13.507..61.849 rows=502671.00 loops=1)
     85  Recheck Cond: (((status)::text = 'SCHEDULED'::text) AND (appointment_date >= CURRENT_DATE))
     86  Heap Blocks: exact=10310
     87  Buffers: shared hit=10750
     88  ->  Bitmap Index Scan on idx_appointments_status_date  (cost=0.00..3393.34 rows=250091 width=0) (actual time=12.486..12.487 rows=502671.00 loops=1)
     89        Index Cond: (((status)::text = 'SCHEDULED'::text) AND (appointment_date >= CURRENT_DATE))
     90        Index Searches: 1
     91        Buffers: shared hit=440
     92Planning Time: 0.093 ms
     93Execution Time: 78.882 ms
     94}}}
     95
     96'''Average execution time WITH index''': 85.23ms
     97
     98'''Improvement: ~52.0%'''
     99
     100Because the execution time has nearly halved and the query planner independently switched from a Seq Scan to a Bitmap Heap Scan using the new index (disk buffer reads dropped from ~9,000 to ~440 per execution), we will keep the index.
     101
     102=== Scenario 2: Performed Lab Tests Date Range Query ===
     103
     104This scenario tests a '''date-range filter''' on a very large table contaning 2M rows with lower selectivity (~8%), where an index on the date column can reduce I/O significantly despite the planner's initial preference for parallel sequential scans.
     105
     106Making performed_lab_tests table have 2 million rows:
     107
     108{{{
     109WITH test_ids AS (
     110    SELECT array_agg(test_id) AS ids FROM lab_tests
     111),
     112patient_ids AS (
     113    SELECT array_agg(patient_id) AS ids FROM patients
     114),
     115doctor_ids AS (
     116    SELECT array_agg(doctor_id) AS ids FROM doctors
     117),
     118technician_ids AS (
     119    SELECT array_agg(technician_id) AS ids FROM lab_technician
     120)
     121INSERT INTO performed_lab_tests (performed_test_id, test_id, patient_id, doctor_id, technician_id, test_date, notes)
     122SELECT
     123    (SELECT COALESCE(MAX(performed_test_id), 0) FROM performed_lab_tests) + gs,
     124    (SELECT ids[floor(random()*array_length(ids,1))+1] FROM test_ids),
     125    (SELECT ids[floor(random()*array_length(ids,1))+1] FROM patient_ids),
     126    (SELECT ids[floor(random()*array_length(ids,1))+1] FROM doctor_ids),
     127    (SELECT ids[floor(random()*array_length(ids,1))+1] FROM technician_ids),
     128    CURRENT_DATE - (random() * 90)::INT,
     129    'Performance test - auto-generated'
     130FROM generate_series(1, 2000000) gs;
     131}}}
     132
     133No triggers exist on `performed_lab_tests`, so no disable/re-enable step was needed for this table.
     134
     135Final data was verified to have realistic ~8-9% selectivity for the target query before benchmarking (7 days out of a 90-day distribution):
     136
     137{{{
     138total = 2,000,032
     139matching filter (test_date >= CURRENT_DATE - INTERVAL '7 days' AND test_date <= CURRENT_DATE) = 167,547
     140selectivity ≈ 8.4%
     141}}}
     142
     143Executing the query 10 times (plus 1 discarded warm-up run) to see average execution time:
     144
     145{{{
     146EXPLAIN (ANALYZE, BUFFERS)
     147SELECT performed_test_id, test_id, patient_id, doctor_id, test_date
     148FROM performed_lab_tests
     149WHERE test_date >= CURRENT_DATE - INTERVAL '7 days'
     150  AND test_date <= CURRENT_DATE;
     151}}}
     152
     153After the first recorded execution:
     154
     155{{{
     156Gather  (cost=1000.00..63198.60 rows=167803 width=36) (actual time=0.832..189.052 rows=167547.00 loops=1)
     157  Workers Planned: 2
     158  Workers Launched: 2
     159  Buffers: shared hit=1948 read=24720
     160  ->  Parallel Seq Scan on performed_lab_tests  (cost=0.00..45418.30 rows=69918 width=36) (actual time=0.357..138.538 rows=55849.00 loops=3)
     161        Filter: ((test_date <= CURRENT_DATE) AND (test_date >= (CURRENT_DATE - '7 days'::interval)))
     162        Rows Removed by Filter: 610828
     163        Buffers: shared hit=1948 read=24720
     164Planning Time: 0.087 ms
     165Execution Time: 200.140 ms
     166}}}
     167
     168'''Average execution time WITHOUT index''': 203.30ms
     169
     170Created the index:
     171
     172{{{
     173CREATE INDEX idx_performed_lab_tests_date
     174    ON performed_lab_tests (test_date DESC);
     175}}}
     176
     177After the first recorded execution with the index present:
     178
     179{{{
     180Bitmap Heap Scan on performed_lab_tests  (cost=2292.42..32735.98 rows=167803 width=36) (actual time=15.679..153.700 rows=167547.00 loops=1)
     181  Recheck Cond: ((test_date >= (CURRENT_DATE - '7 days'::interval)) AND (test_date <= CURRENT_DATE))
     182  Heap Blocks: exact=26626
     183  Buffers: shared read=26770 written=6
     184  ->  Bitmap Index Scan on idx_performed_lab_tests_date  (cost=0.00..2250.46 rows=167803 width=0) (actual time=12.123..12.124 rows=167547.00 loops=1)
     185        Index Cond: ((test_date >= (CURRENT_DATE - '7 days'::interval)) AND (test_date <= CURRENT_DATE))
     186        Index Searches: 1
     187        Buffers: shared read=144
     188Planning Time: 0.092 ms
     189Execution Time: 165.364 ms
     190}}}
     191
     192'''Average execution time WITH index''': 152.66ms
     193
     194'''Improvement: ~24.9%'''
     195
     196Because the execution time improved by ~25% and the query planner independently abandoned its parallel sequential scan strategy (2 workers) in favor of a Bitmap Heap Scan using the new index, we will keep the index. This is a smaller improvement than Scenario 1, which is expected: this scenario tests a much lower selectivity (~8.4% vs ~50%), and the planner's willingness to abandon a parallel scan strategy — normally one of its strongest non-index options — for the index is itself a strong signal the index is genuinely earning its keep here, not just marginally useful.
     197
     198=== Scenario 3: Billing Table with JOIN and ORDER BY ===
     199
     200This scenario tests a JOIN across multiple tables with a filter and a sort. It shows two things: a composite index on the filter column doesn't help if the query wraps that column in a calculation, and even after rewriting the filter to compare the column directly and the index still isn't used because too large a share of the table matches the filter, so a full scan stays cheaper than using the index.
     201
     202Making billing table have 1 million rows:
     203
     204{{{
     205WITH record_ids AS (
     206    SELECT array_agg(record_id) AS ids FROM medical_records
     207),
     208base AS (
     209    SELECT
     210        gs,
     211        (ARRAY['PENDING', 'PAID', 'CANCELLED'])[floor(random()*3)+1] AS status,
     212        (NOW() - (random() * INTERVAL '180 days'))::TIMESTAMP AS created_date
     213    FROM generate_series(1, 1000000) gs
     214)
     215INSERT INTO billing (bill_id, record_id, total_cost, payment_status, payment_date, created_at)
     216SELECT
     217    (SELECT COALESCE(MAX(bill_id), 0) FROM billing) + gs,
     218    (SELECT ids[floor(random()*array_length(ids,1))+1] FROM record_ids),
     219    (random() * 2000 + 20)::numeric(12,2),
     220    status,
     221    CASE WHEN status = 'PAID' THEN (created_date + (random() * 30 || ' days')::INTERVAL)::DATE ELSE NULL END,
     222    created_date
     223FROM base;
     224}}}
     225
     226No triggers fire on `INSERT` for the `billing` table (`trg_billing_status_transition` only fires on `UPDATE`), so no disable/re-enable step was needed.
     227
     228Final data was verified to have ~28% selectivity for the target query before benchmarking:
     229
     230{{{
     231total = 1,000,022
     232pending_count = 333,340
     233matching filter (payment_status = 'PENDING' AND CURRENT_DATE - created_at::DATE >= 30) = 278,616
     234selectivity ≈ 27.9%
     235}}}
     236
     237Unlike Scenarios 1 and 2, this scenario tests a JOIN across three tables (`billing`, `medical_records`, `patients`) with a filter ''and'' an `ORDER BY` on a value derived from the indexed column, to see whether a composite index can eliminate both the filter scan and the sort step in one pass.
     238
     239{{{
     240EXPLAIN (ANALYZE, BUFFERS)
     241SELECT
     242    b.bill_id,
     243    p.patient_id, p.first_name, p.last_name,
     244    b.total_cost, b.payment_status, b.created_at,
     245    CURRENT_DATE - b.created_at::DATE AS days_outstanding
     246FROM billing b
     247JOIN medical_records mr ON b.record_id = mr.record_id
     248JOIN patients p ON mr.patient_id = p.patient_id
     249WHERE b.payment_status = 'PENDING'
     250  AND CURRENT_DATE - b.created_at::DATE >= 30
     251ORDER BY days_outstanding DESC;
     252}}}
     253
     254After the first recorded execution (without index):
     255
     256{{{
     257Nested Loop  (cost=22776.01..39200.16 rows=110425 width=1073) (actual time=117.677..330.399 rows=278616.00 loops=1)
     258  ->  Gather Merge  (cost=22775.86..35636.67 rows=110425 width=37) (actual time=117.618..201.318 rows=278616.00 loops=1)
     259        Workers Planned: 2
     260        Workers Launched: 2
     261        ->  Sort  (cost=21775.84..21890.86 rows=46010 width=37) (actual time=84.710..95.540 rows=92872.00 loops=3)
     262              Sort Key: ((CURRENT_DATE - (b.created_at)::date)) DESC
     263              Sort Method: external merge  Disk: 4816kB
     264              ->  Hash Join  (cost=1.34..18212.44 rows=46010 width=37) (actual time=0.323..54.310 rows=92872.00 loops=3)
     265                    Hash Cond: (b.record_id = mr.record_id)
     266                    ->  Parallel Seq Scan on billing b  (cost=0.00..18056.21 rows=46010 width=37) (actual time=0.249..37.559 rows=92872.00 loops=3)
     267                          Filter: (((payment_status)::text = 'PENDING'::text) AND ((CURRENT_DATE - (created_at)::date) >= 30))
     268                          Rows Removed by Filter: 240469
     269                    ->  Hash  (cost=1.15..1.15 rows=15 width=16)
     270                          ->  Seq Scan on medical_records mr  (cost=0.00..1.15 rows=15 width=16)
     271  ->  Memoize (Cache Key: mr.patient_id)
     272        ->  Index Scan using patients_pk on patients p
     273Planning Time: 0.174 ms
     274Execution Time: 345.700 ms
     275}}}
     276
     277'''Average execution time WITHOUT index''' (n=3): 354.67ms
     278
     279Created the index:
     280
     281{{{
     282CREATE INDEX idx_billing_status_created
     283    ON billing (payment_status, created_at DESC);
     284}}}
     285
     286Re-ran the identical query with the index present. The plan was unchanged, still a `Parallel Seq Scan` on `billing`, no reference to `idx_billing_status_created` anywhere in the plan:
     287
     288{{{
     289Nested Loop  (cost=22776.01..39200.16 rows=110425 width=1073) (actual time=116.693..322.849 rows=278616.00 loops=1)
     290  ->  Gather Merge  (cost=22775.86..35636.67 rows=110425 width=37) (actual time=116.660..198.802 rows=278616.00 loops=1)
     291        ...
     292        ->  Sort  (cost=21775.84..21890.86 rows=46010 width=37) (actual time=85.462..96.482 rows=92872.00 loops=3)
     293              Sort Key: ((CURRENT_DATE - (b.created_at)::date)) DESC
     294              Sort Method: external merge  Disk: 4776kB
     295              ->  Hash Join  (cost=1.34..18212.44 rows=46010 width=37) (actual time=0.331..54.864 rows=92872.00 loops=3)
     296                    ->  Parallel Seq Scan on billing b  (cost=0.00..18056.21 rows=46010 width=37) (actual time=0.255..38.297 rows=92872.00 loops=3)
     297                          Filter: (((payment_status)::text = 'PENDING'::text) AND ((CURRENT_DATE - (created_at)::date) >= 30))
     298                          Rows Removed by Filter: 240469
     299...
     300Planning Time: 0.186 ms
     301Execution Time: 337.457 ms
     302}}}
     303
     304'''Average execution time WITH index, same query as written''': 346.13ms so no meaningful change from baseline, and no index usage in the plan.
     305
     306**Why the index wasn't used:** the filter CURRENT_DATE - b.created_at::DATE >= 30 does math on the indexed column before comparing it. PostgreSQL can't turn a calculation like that into an index lookup, so it evaluates the filter row-by-row with a full scan instead so whether or not a matching index exists.
     307
     308Scenario 3b - rewriting the filter so the index can be used
     309
     310To find out whether the index itself was the problem, or just how the filter was written, the query was rewritten to compare created_at directly, with no calculation around it:
     311
     312{{{
     313EXPLAIN (ANALYZE, BUFFERS)
     314SELECT
     315    b.bill_id,
     316    p.patient_id, p.first_name, p.last_name,
     317    b.total_cost, b.payment_status, b.created_at,
     318    CURRENT_DATE - b.created_at::DATE AS days_outstanding
     319FROM billing b
     320JOIN medical_records mr ON b.record_id = mr.record_id
     321JOIN patients p ON mr.patient_id = p.patient_id
     322WHERE b.payment_status = 'PENDING'
     323  AND b.created_at <= (CURRENT_DATE - 30)
     324ORDER BY days_outstanding DESC;
     325}}}
     326
     327{{{
     328Nested Loop  (cost=31149.76..71980.89 rows=274670 width=1073) (actual time=111.568..296.262 rows=276804.00 loops=1)
     329  ->  Gather Merge  (cost=31149.61..63139.46 rows=274670 width=37) (actual time=111.517..185.352 rows=276804.00 loops=1)
     330        ->  Sort  (cost=30149.59..30435.70 rows=114446 width=37) (actual time=81.512..90.942 rows=92268.00 loops=3)
     331              Sort Key: ((CURRENT_DATE - (b.created_at)::date)) DESC
     332              Sort Method: external merge  Disk: 4768kB
     333              ->  Hash Join  (cost=1.34..17401.16 rows=114446 width=37) (actual time=0.127..50.843 rows=92268.00 loops=3)
     334                    ->  Parallel Seq Scan on billing b  (cost=0.00..17014.52 rows=114446 width=37) (actual time=0.054..34.252 rows=92268.00 loops=3)
     335                          Filter: (((payment_status)::text = 'PENDING'::text) AND (created_at <= (CURRENT_DATE - 30)))
     336                          Rows Removed by Filter: 241073
     337...
     338Planning Time: 0.213 ms
     339Execution Time: 310.265 ms
     340}}}
     341
     342'''Average execution time WITH index, sargable filter''': 319.74ms
     343
     344Even with the rewrite, the plan still shows Parallel Seq Scan the index still isn't used. The ~10% speed-up comes from the filter itself being cheaper to check (a plain comparison instead of a date calculation on every row), not from using the index.
     345
     346'''Overall Scenario 3 conclusion'''
     347
     348At ~27.9% selectivity, `idx_billing_status_created` was never picked by the planner, in any of the three versions tested — the original filter, the original filter with the index added, or the rewritten sargable filter with the index. This matches what we saw with `idx_performed_lab_tests_date` in Scenario 2: once a large enough share of the table matches the filter, PostgreSQL decides a full scan is cheaper than using the index. Rewriting the filter to be sargable is still worth doing because it gave a real ~10% speed-up, but it didn't make the index get used here.
     349
     350'''Decision: DROP''' `idx_billing_status_created` for this query as currently written, or as a general index because it adds write overhead to every future `INSERT`/`UPDATE` on `billing` without providing any read benefit for this report.
     351
     352=== Scenario 4: Appointments Trigger Write-Path Optimization ===
     353
     354'''This scenario tests whether an index can optimize '''write-path trigger performance''', specifically, can a composite index reduce the cost of overlap-checking subqueries that run on every insert, rather than speeding up a read query.
     355
     356Unlike Scenarios 1-3 (all SELECT/read-path queries), this scenario tests something structurally different: whether an index reduces the cost of a '''write-path trigger'''. The `trigger_appointments_no_overlap` trigger runs two `EXISTS` subqueries, a doctor-side check and a patient-side check on every `INSERT` into `appointments`, to prevent double-booking. Since `EXPLAIN ANALYZE` on an `INSERT` doesn't break out trigger execution time separately, this was measured by timing individual `INSERT` statements end-to-end (wall-clock, via `clock_timestamp()`), 1 discarded warm-up + 10 recorded inserts per condition, each using a distinct far-future date so no insert collides with existing data or with the other test rows.
     357
     358{{{
     359CREATE INDEX idx_appointments_doctor_datetime
     360    ON appointments (doctor_id, appointment_date, appointment_time);
     361}}}
     362
     363'''Baseline (10 single-row inserts, WITHOUT index):'''
     364
     365{{{
     366230.834, 215.470, 224.593, 292.890, 259.240, 303.525, 275.669, 281.612, 256.227, 258.670  (ms)
     367Average: 259.873 ms
     368}}}
     369
     370'''Indexed (10 single-row inserts, WITH index):'''
     371
     372{{{
     373319.400, 277.986, 273.792, 264.343, 268.986, 234.145, 249.148, 254.938, 246.849, 210.346  (ms)
     374Average: 259.993 ms
     375}}}
     376
     377'''Improvement: -0.05%''' - no meaningful difference; if anything, marginally slower.
     378
     379'''Why the index made no difference:''' `t1_appointments_no_overlap()` runs two separate overlap checks, one filtered on `doctor_id`, one filtered on `patient_id`. `idx_appointments_doctor_datetime` only supports the doctor-side check. The patient-side check has no supporting index at all and remains a full scan on every insert regardless. Since both checks run on every insert, the unindexed patient-side check dominates the total time, masking any improvement on the doctor side.
     380
     381'''Decision: DROP''' `idx_appointments_doctor_datetime` as currently designed. A single-sided index cannot meaningfully help a trigger that checks both sides of a relationship — a real fix would require a matching index on `patient_id` as well, which is outside the scope of what was tested here.
     382
     383
     384=== Scenario 5: Referral Loop Detection via Recursive CTE ===
     385
     386'''This scenario tests a '''recursive graph traversal''' where the same join is repeated at every recursion level, so a modest per-lookup saving in the index compounds across all levels—the opposite of Scenario 3, where a high-selectivity filter made the planner prefer a full scan.
     387
     388This scenario is different again: it uses a recursive CTE to follow chains of doctor-to-doctor referrals and find indirect loops (Dr. A refers to Dr. B, who refers to Dr. C, who refers back to Dr. A). This matters because the existing trigger, `trg_referral_consistency`, only catches a doctor referring directly to themselves — it can't catch a multi-hop loop, since each individual INSERT looks fine on its own. Spotting a loop like this means tracing paths across the whole table, which only a query can do — a row-level trigger can't see far enough.
     389
     390{{{
     391WITH RECURSIVE referral_chain AS (
     392    SELECT
     393        r.referral_id, r.from_doctor_id, r.to_doctor_id, r.referral_date,
     394        ARRAY[r.from_doctor_id, r.to_doctor_id] AS path, 1 AS depth
     395    FROM referrals r
     396    UNION ALL
     397    SELECT
     398        r.referral_id, rc.from_doctor_id, r.to_doctor_id, r.referral_date,
     399        rc.path || r.to_doctor_id, rc.depth + 1
     400    FROM referrals r
     401    JOIN referral_chain rc ON r.from_doctor_id = rc.to_doctor_id
     402    WHERE rc.depth < 5
     403      AND r.to_doctor_id <> ALL(rc.path[2:])
     404)
     405SELECT rc.from_doctor_id, d1.first_name, d1.last_name, rc.depth, rc.path, rc.referral_date
     406FROM referral_chain rc
     407JOIN doctors d1 ON d1.doctor_id = rc.from_doctor_id
     408WHERE rc.depth > 1 AND rc.to_doctor_id = rc.from_doctor_id
     409ORDER BY rc.depth DESC, rc.referral_date DESC
     410LIMIT 50;
     411}}}
     412
     413**Data setup and correctness check:** the `referrals` table started with only 13 rows — far too small for an index to matter, and with no real loops in it, so the query had never actually caught a positive case. The table was bulk-populated to ~50,000 rows using the same insert pattern as earlier scenarios, then a guaranteed 3-hop loop was seeded by hand (Dr. 1 → Dr. 2 → Dr. 3 → Dr. 1). Re-running the query confirmed it catches the seeded loop, returning it once from each doctor's point of view in the cycle — which is what you'd expect from how the traversal works:
     414
     415{{{
     416from_doctor_id=2, to_doctor_id=2, depth=3, path={2,3,1,2}
     417from_doctor_id=3, to_doctor_id=3, depth=3, path={3,1,2,3}
     418from_doctor_id=1, to_doctor_id=1, depth=3, path={1,2,3,1}
     419}}}
     420
     421'''Baseline (10 runs, WITHOUT index):'''
     422
     423{{{
     424Merge Join  (cost=36388.55..1381897.00 ...)
     425  Merge Cond: (r_1.from_doctor_id = rc_1.to_doctor_id)
     426  ->  Sort  (cost=4972.93..5097.97 rows=50016 width=28) (actual time=13.909..16.054 rows=40013.00 loops=5)
     427        Sort Key: r_1.from_doctor_id
     428        Sort Method: quicksort  Memory: 3710kB
     429        ->  Seq Scan on referrals r_1  (cost=0.00..1069.16 rows=50016 width=28) ...
     430}}}
     431
     432Execution times (ms): 169.772, 192.549, 165.460, 171.478, 172.596, 172.093, 165.839, 150.697, 191.878, 149.140
     433
     434'''Average execution time WITHOUT index''': 170.15ms
     435
     436Created the index:
     437
     438{{{
     439CREATE INDEX idx_referrals_from_doctor
     440    ON referrals (from_doctor_id, to_doctor_id, referral_date);
     441}}}
     442
     443'''Indexed (10 runs, WITH index):'''
     444
     445{{{
     446Limit  (cost=27687563.92..27687564.04 rows=50 width=1080) (actual time=150.504..150.509 rows=3.00 loops=1)
     447  Buffers: shared hit=159918, temp read=836 written=1457
     448  CTE referral_chain
     449    ->  Recursive Union  (cost=0.00..17757623.19 rows=396599576 width=64) (actual time=0.016..137.869 rows=50065.00 loops=1)
     450          Storage: Disk  Maximum Storage: 4114kB
     451          Buffers: shared hit=159916, temp read=836 written=956
     452          ->  Seq Scan on referrals r  (cost=0.00..1069.16 rows=50016 width=64) (actual time=0.015..5.964 rows=50016.00 loops=1)
     453                Buffers: shared hit=569
     454          ->  Merge Join  (cost=31415.91..1379055.83 rows=39654956 width=64) (actual time=8.380..24.306 rows=9.80 loops=5)
     455                Merge Cond: (r_1.from_doctor_id = rc_1.to_doctor_id)
     456                Join Filter: (r_1.to_doctor_id <> ALL (rc_1.path[2:]))
     457                Rows Removed by Join Filter: 1
     458                Buffers: shared hit=159347, temp read=836 written=456
     459                ->  Index Scan using idx_referrals_from_doctor on referrals r_1  (cost=0.29..2256.80 rows=50016 width=28) (actual time=0.012..12.456 rows=40013.00 loops=5)
     460                      Index Searches: 5
     461                      Buffers: shared hit=159347
     462                ->  Materialize  (cost=31415.62..32249.22 rows=166720 width=52) (actual time=8.335..8.340 rows=15.40 loops=5)
     463                      Storage: Memory  Maximum Storage: 17kB
     464                      Buffers: temp read=836 written=456
     465                      ->  Sort  (cost=31415.62..31832.42 rows=166720 width=52) (actual time=8.121..8.122 rows=11.80 loops=5)
     466                            Sort Key: rc_1.to_doctor_id
     467                            Sort Method: quicksort  Memory: 25kB
     468                            Buffers: temp read=836 written=456
     469                            ->  WorkTable Scan on referral_chain rc_1  (cost=0.00..11253.60 rows=166720 width=52) (actual time=0.006..2.463 rows=10011.80 loops=5)
     470                                  Filter: (depth < 5)
     471                                  Rows Removed by Filter: 1
     472                                  Buffers: temp read=501 written=1
     473  ->  Sort  (cost=9929940.72..9930932.22 rows=396599 width=1080) (actual time=150.503..150.505 rows=3.00 loops=1)
     474        Sort Key: rc.depth DESC, rc.referral_date DESC
     475        Sort Method: quicksort  Memory: 25kB
     476        Buffers: shared hit=159918, temp read=836 written=1457
     477        ->  Hash Join  (cost=4.70..9916765.99 rows=396599 width=1080) (actual time=91.359..150.495 rows=3.00 loops=1)
     478              Hash Cond: (rc.from_doctor_id = d1.doctor_id)
     479              Buffers: shared hit=159918, temp read=836 written=1457
     480              ->  CTE Scan on referral_chain rc  (cost=0.00..9914989.40 rows=660999 width=56) (actual time=91.287..150.418 rows=3.00 loops=1)
     481                    Filter: ((depth > 1) AND (from_doctor_id = to_doctor_id))
     482                    Rows Removed by Filter: 50062
     483                    Storage: Disk  Maximum Storage: 4096kB
     484                    Buffers: shared hit=159916, temp read=836 written=1457
     485              ->  Hash  (cost=3.20..3.20 rows=120 width=1040) (actual time=0.060..0.061 rows=121.00 loops=1)
     486                    Buckets: 1024  Batches: 1  Memory Usage: 15kB
     487                    Buffers: shared hit=2
     488                    ->  Seq Scan on doctors d1  (cost=0.00..3.20 rows=120 width=1040) (actual time=0.024..0.036 rows=121.00 loops=1)
     489                          Buffers: shared hit=2
     490Planning Time: 0.246 ms
     491Execution Time: 152.918 ms
     492}}}
     493
     494Execution times (ms): 152.918, 171.109, 147.609, 144.191, 157.394, 161.768, 163.216, 145.951, 159.057, 145.346
     495
     496'''Average execution time WITH index''': 154.86ms
     497
     498'''Improvement: ~9.0%'''
     499
     500The percentage improvement looks modest on its own, but the query plan tells a clearer story: the baseline plan needed an explicit `Sort` on `referrals.from_doctor_id` at every recursive step, since the table wasn't already ordered that way. The indexed plan skips that entirely — a single `Index Scan` returns the rows pre-sorted, cutting out a whole step rather than just making it faster.
     501
     502**Decision: KEEP** `idx_referrals_from_doctor`. Scenarios 1-2 asked "does this index help filter the table once?" This one's different: the saving per lookup is small, but it adds up because the same lookup happens at every level of the recursion. It's also the only scenario where the real question isn't about filtering rows, but about how cheaply the same join can be repeated at scale.
     503
     504== Security measures ==
     505
     506=== SQL Injection Prevention ===
     507
     508The application protects against SQL injection attacks by using parameterized queries through Spring Data JPA. User-provided input is never directly concatenated into SQL or JPQL statements. Instead, input values are passed as parameters and handled as data by the persistence framework.
     509
     510=== JPQL Queries with Named Parameters ===
     511
     512{{{
     513@Query("""
     514SELECT b FROM Billing b
     515WHERE b.medicalRecord.patient.patientId = :patientId
     516ORDER BY b.paymentDate DESC
     517""")
     518List<Billing> findBillingHistoryForPatient(
     519    @Param("patientId") Long patientId
     520);
     521}}}
     522
     523The query uses the named parameter `:patientId` instead of putting user input directly into the JPQL statement. Spring Data JPA and Hibernate bind the value of `patientId` separately from the query structure, so it's treated as a parameter, not part of the JPQL command — meaning an attacker can't use malicious input to alter the query or inject additional conditions or SQL commands through the `patientId` value. This prevents SQL injection in database operations that rely on user-provided identifiers.
     524
     525=== Native SQL Queries with Parameters ===
     526
     527{{{
     528@Query(value = """
     529SELECT COALESCE(SUM(p.cost), 0) + COALESCE(SUM(l.cost), 0)
     530FROM medical_records mr
     531LEFT JOIN medical_record_procedures mrp ON mr.record_id = mrp.record_id
     532LEFT JOIN procedures p ON mrp.procedure_id = p.procedure_id
     533LEFT JOIN medical_record_lab_results mrl ON mr.record_id = mrl.record_id
     534LEFT JOIN lab_results lr ON mrl.result_id = lr.result_id
     535LEFT JOIN lab_tests l ON lr.test_id = l.test_id
     536WHERE mr.record_id = :recordId
     537""", nativeQuery = true)
     538BigDecimal calculateTotalCostForMedicalRecord(
     539    @Param("recordId") Long recordId
     540);
     541}}}
     542
     543Native SQL queries also use named parameters such as `:recordId`, with the value bound separately from the SQL statement through `@Param`. This means user input can't change the SQL syntax or append additional SQL commands to the query — even a malicious value is treated as a parameter value rather than executable SQL. This prevents SQL injection in native database queries.
     544
     545=== Prevention of Unsafe Dynamic SQL ===
     546
     547The application does not construct SQL queries by concatenating user input with SQL strings.
     548
     549For example, the following unsafe approach is not used:
     550
     551{{{
     552String query = "SELECT * FROM patients WHERE embg = '" + embg + "'";
     553}}}
     554
     555Directly concatenating user input into a SQL statement could allow an attacker to manipulate the query and potentially access, modify, or delete unauthorized data.
     556
     557Instead, the application uses Spring Data JPA parameter binding, where user input is kept separate from the SQL or JPQL command.
     558
     559In conclusion SQL injection is prevented by keeping user input separate from database commands. The application uses Spring Data JPA method queries, named parameters with `@Param`, and parameterized native SQL queries. As a result, user-provided values are treated as data rather than executable SQL, preventing attackers from modifying the intended database queries through malicious input.
     560
     561=== Database-Level Security & Access Control ===
     562
     563Beyond application-layer parameterized queries, data access is protected through:
     564
     565* '''Service-Layer Ownership Checks''': Resource-level access control is enforced at the service layer. For example, patients can only access their own medical records, appointments, and billing information. Doctors can only modify their own appointment schedules and patient records they have access to. These checks verify that the user's ID or role from the JWT token matches the resource being accessed before any data is returned or modified.
     566
     567=== CORS ===
     568
     569The application uses a custom CORS filter to control which frontend clients can communicate with the backend API. In development, requests are allowed only from the trusted origins `http://localhost:3000` and `http://localhost:3001`. The filter also restricts the allowed HTTP methods and headers and rejects preflight requests from untrusted origins.
     570
     571{{{
     572package medora.config;
     573
     574import org.springframework.stereotype.Component;
     575import jakarta.servlet.Filter;
     576import jakarta.servlet.FilterChain;
     577import jakarta.servlet.ServletException;
     578import jakarta.servlet.ServletRequest;
     579import jakarta.servlet.ServletResponse;
     580import jakarta.servlet.http.HttpServletRequest;
     581import jakarta.servlet.http.HttpServletResponse;
     582
     583import java.io.IOException;
     584
     585@Component
     586public class CorsFilter implements Filter {
     587
     588    @Override
     589    public void doFilter(
     590            ServletRequest request,
     591            ServletResponse response,
     592            FilterChain chain)
     593            throws IOException, ServletException {
     594
     595        HttpServletRequest httpRequest = (HttpServletRequest) request;
     596        HttpServletResponse httpResponse = (HttpServletResponse) response;
     597
     598        String origin = httpRequest.getHeader("Origin");
     599
     600        // Allow only trusted frontend origins
     601        boolean allowedOrigin =
     602                "http://localhost:3000".equals(origin) ||
     603                "http://localhost:3001".equals(origin);
     604
     605        if (allowedOrigin) {
     606            httpResponse.setHeader(
     607                    "Access-Control-Allow-Origin",
     608                    origin
     609            );
     610
     611            httpResponse.setHeader(
     612                    "Access-Control-Allow-Methods",
     613                    "GET, POST, PUT, PATCH, DELETE, OPTIONS"
     614            );
     615
     616            httpResponse.setHeader(
     617                    "Access-Control-Allow-Headers",
     618                    "Content-Type, Authorization"
     619            );
     620
     621            httpResponse.setHeader(
     622                    "Access-Control-Allow-Credentials",
     623                    "true"
     624            );
     625
     626            httpResponse.setHeader(
     627                    "Access-Control-Max-Age",
     628                    "3600"
     629            );
     630        }
     631
     632        // Handle CORS preflight requests
     633        if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) {
     634            if (allowedOrigin) {
     635                httpResponse.setStatus(HttpServletResponse.SC_OK);
     636            } else {
     637                httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
     638            }
     639            return;
     640        }
     641
     642        chain.doFilter(request, response);
     643    }
     644}
     645}}}
     646
     647=== Password Storage ===
     648
     649The application uses BCrypt to protect user passwords. Passwords are hashed using `PasswordEncoder` before being stored in the database, while login attempts are verified using BCrypt's password matching functionality.
     650
     651{{{
     652String hashedPassword = passwordEncoder.encode(password);
     653User user = new User(username, hashedPassword, role, firstName, lastName);
     654userRepository.save(user);
     655}}}
     656
     657* Passwords are not stored as plaintext; only the BCrypt hash is saved in the database.
     658* During login, `passwordEncoder.matches()` compares the provided password with the stored hash without exposing or storing the original password.
     659
     660{{{
     661if (!passwordEncoder.matches(password, user.getPassword())) {
     662    throw new RuntimeException("Invalid username or password");
     663}
     664}}}
     665
     666=== JWT Authentication ===
     667
     668The application uses JWT tokens to authenticate users. All API endpoints, except authentication and health-check endpoints, require a valid JWT token.
     669The `AuthorizationInterceptor` validates the token using `SecurityUtil`. Requests with a missing or invalid token are rejected with `HTTP 401 Unauthorized`.
     670
     671{{{
     672if (!securityUtil.isValidToken(request)) {
     673    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
     674    return false;
     675}
     676}}}
     677
     678== Role-Based Authorization ==
     679
     680The application uses the role stored in the JWT to restrict access to different API endpoints.
     681* Admin endpoints require the `ADMIN` role
     682* Doctor endpoints require `DOCTOR` or `ADMIN`
     683* Patient endpoints require `PATIENT`, `DOCTOR`, or `ADMIN`
     684
     685Requests from users without the required role are rejected with `HTTP 403 Forbidden`.
     686
     687{{{
     688if (path.startsWith("/api/admin/")) {
     689    if (!securityUtil.hasRole("ADMIN", request)) {
     690        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
     691        return false;
     692    }
     693}
     694}}}
     695
     696* Resource ownership checks, such as ensuring that a patient can access only their own records, are handled separately in the corresponding service methods.