001package com.studentgui.apphelpers; 002 003import java.sql.Connection; 004import java.sql.DriverManager; 005import java.sql.PreparedStatement; 006import java.sql.ResultSet; 007import java.sql.SQLException; 008import java.sql.Statement; 009import java.time.LocalDate; 010import java.util.ArrayList; 011import java.util.HashMap; 012import java.util.List; 013import java.util.Map; 014 015/** 016 * Centralized database helper for the normalized SQLite schema. 017 * 018 * <p>Provides convenience methods to get-or-create Students and ProgressTypes, 019 * create ProgressSessions, ensure AssessmentParts, insert/fetch assessment 020 * results, and save session-specific notes. Use these helpers instead of 021 * running per-page DDL throughout the codebase.</p> 022 */ 023public class Database { 024 025 /** 026 * Private constructor to prevent instantiation of this utility class. 027 */ 028 private Database() { 029 throw new AssertionError("Database is a utility class"); 030 } 031 032 /** 033 * Obtain a new JDBC Connection to the application SQLite database. 034 * Caller is responsible for closing the connection (try-with-resources is recommended). 035 * 036 * @return new Connection 037 * @throws SQLException if the driver cannot open the database 038 */ 039 private static Connection getConnection() throws SQLException { 040 String url = "jdbc:sqlite:" + Helpers.DATABASE_PATH.toString(); 041 return DriverManager.getConnection(url); 042 } 043 044 /** 045 * Get a student id by name, creating a new Student row when none exists. 046 * 047 * @param name student display name 048 * @return id of the existing or newly created student 049 * @throws SQLException on database errors 050 */ 051 public static int getOrCreateStudent(final String name) throws SQLException { 052 try (Connection c = getConnection()) { 053 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) { 054 ps.setString(1, name); 055 try (ResultSet rs = ps.executeQuery()) { 056 if (rs.next()) { 057 return rs.getInt(1); 058 } 059 } 060 } 061 try (PreparedStatement ps = c.prepareStatement("INSERT INTO Student(name) VALUES (?)", Statement.RETURN_GENERATED_KEYS)) { 062 ps.setString(1, name); 063 ps.executeUpdate(); 064 try (ResultSet keys = ps.getGeneratedKeys()) { 065 if (keys.next()) { 066 return keys.getInt(1); 067 } 068 } 069 } 070 } 071 throw new SQLException("Failed to create or retrieve student"); 072 } 073 074 /** 075 * Get or create a ProgressType row by name. 076 * 077 * @param name progress type display name 078 * @return database id of the progress type 079 * @throws SQLException on database errors 080 */ 081 public static int getOrCreateProgressType(final String name) throws SQLException { 082 try (Connection c = getConnection()) { 083 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) { 084 ps.setString(1, name); 085 try (ResultSet rs = ps.executeQuery()) { 086 if (rs.next()) { 087 return rs.getInt(1); 088 } 089 } 090 } 091 try (PreparedStatement ps = c.prepareStatement("INSERT INTO ProgressType(name) VALUES (?)", Statement.RETURN_GENERATED_KEYS)) { 092 ps.setString(1, name); 093 ps.executeUpdate(); 094 try (ResultSet keys = ps.getGeneratedKeys()) { 095 if (keys.next()) { 096 return keys.getInt(1); 097 } 098 } 099 } 100 } 101 throw new SQLException("Failed to create or retrieve ProgressType"); 102 } 103 104 /** 105 * Ensure AssessmentPart rows exist for the given progress type. This uses 106 * SQL "INSERT OR IGNORE" so existing parts are preserved. 107 * 108 * @param progressTypeId id of the ProgressType 109 * @param codes array of part codes to ensure 110 * @throws SQLException on database errors 111 */ 112 public static void ensureAssessmentParts(final int progressTypeId, final String[] codes) throws SQLException { 113 try (Connection c = getConnection()) { 114 try (PreparedStatement ps = c.prepareStatement("INSERT OR IGNORE INTO AssessmentPart(progress_type_id, code, description) VALUES (?, ?, NULL)")) { 115 for (String code : codes) { 116 ps.setInt(1, progressTypeId); 117 ps.setString(2, code); 118 ps.addBatch(); 119 } 120 ps.executeBatch(); 121 } 122 } 123 } 124 125 /** 126 * Remove any AssessmentPart rows for the given progress type whose code is 127 * not present in the provided canonical codes array. This helps clean up 128 * legacy/malformed entries that could cause part ordering mismatches. 129 * 130 * @param progressTypeId id of the ProgressType 131 * @param allowedCodes canonical set of codes to keep 132 * @throws SQLException on database errors 133 */ 134 public static void cleanupAssessmentParts(final int progressTypeId, final String[] allowedCodes) throws SQLException { 135 if (allowedCodes == null || allowedCodes.length == 0) { 136 return; 137 } 138 try (Connection c = getConnection()) { 139 StringBuilder sb = new StringBuilder(); 140 for (int i = 0; i < allowedCodes.length; i++) { 141 if (i > 0) { sb.append(','); } 142 sb.append('?'); 143 } 144 String sql = "DELETE FROM AssessmentPart WHERE progress_type_id = ? AND code NOT IN (" + sb.toString() + ")"; 145 try (PreparedStatement ps = c.prepareStatement(sql)) { 146 ps.setInt(1, progressTypeId); 147 for (int i = 0; i < allowedCodes.length; i++) { 148 ps.setString(i + 2, allowedCodes[i]); 149 } 150 ps.executeUpdate(); 151 } 152 } 153 } 154 155 /** 156 * Create a ProgressSession for a student and progress type on the given date. 157 * 158 * @param studentId existing student id 159 * @param progressTypeId existing progress type id 160 * @param date session date 161 * @return generated ProgressSession id 162 * @throws SQLException on database errors 163 */ 164 public static int createProgressSession(final int studentId, final int progressTypeId, final LocalDate date) throws SQLException { 165 try (Connection c = getConnection()) { 166 try (PreparedStatement ps = c.prepareStatement("INSERT INTO ProgressSession(student_id, progress_type_id, date, notes) VALUES (?, ?, ?, NULL)", Statement.RETURN_GENERATED_KEYS)) { 167 ps.setInt(1, studentId); 168 ps.setInt(2, progressTypeId); 169 ps.setString(3, date.toString()); 170 ps.executeUpdate(); 171 try (ResultSet keys = ps.getGeneratedKeys()) { 172 if (keys.next()) { return keys.getInt(1); } 173 } 174 } 175 } 176 throw new SQLException("Failed to create ProgressSession"); 177 } 178 179 /** 180 * Insert assessment results for a session. The {@code codes} and {@code scores} 181 * arrays must be parallel and correspond to existing AssessmentPart codes. 182 * Unknown part codes are ignored. 183 * 184 * @param sessionId progress session id 185 * @param progressTypeId progress type id 186 * @param codes array of part codes 187 * @param scores array of integer scores 188 * @throws SQLException on database errors 189 */ 190 public static void insertAssessmentResults(final int sessionId, final int progressTypeId, final String[] codes, final int[] scores) throws SQLException { 191 if (codes.length != scores.length) { throw new IllegalArgumentException("codes and scores length mismatch"); } 192 try (Connection c = getConnection()) { 193 // cache part ids 194 Map<String, Integer> partIdMap = new HashMap<>(); 195 try (PreparedStatement ps = c.prepareStatement("SELECT id, code FROM AssessmentPart WHERE progress_type_id = ?")) { 196 ps.setInt(1, progressTypeId); 197 try (ResultSet rs = ps.executeQuery()) { 198 while (rs.next()) { 199 partIdMap.put(rs.getString("code"), rs.getInt("id")); 200 } 201 } 202 } 203 try (PreparedStatement ins = c.prepareStatement("INSERT INTO AssessmentResult(session_id, part_id, score) VALUES (?, ?, ?)") ) { 204 for (int i = 0; i < codes.length; i++) { 205 Integer partId = partIdMap.get(codes[i]); 206 if (partId == null) { 207 // skip unknown part 208 continue; 209 } 210 ins.setInt(1, sessionId); 211 ins.setInt(2, partId); 212 ins.setInt(3, scores[i]); 213 ins.addBatch(); 214 } 215 ins.executeBatch(); 216 } 217 } 218 } 219 220 /** 221 * Fetch the latest assessment result rows for a named student and progress type. 222 * Each returned row is a list of integer scores for the parts in canonical 223 * part order. 224 * 225 * @param studentName student display name 226 * @param progressTypeName progress type display name 227 * @param limit maximum number of recent sessions to fetch 228 * @return list of rows, each row is a list of integer scores 229 * @throws SQLException on database errors 230 */ 231 public static List<List<Integer>> fetchLatestAssessmentResults(final String studentName, final String progressTypeName, final int limit) throws SQLException { 232 List<List<Integer>> result = new ArrayList<>(); 233 try (Connection c = getConnection()) { 234 Integer studentId = null; 235 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) { 236 ps.setString(1, studentName); 237 try (ResultSet rs = ps.executeQuery()) { 238 if (rs.next()) { studentId = rs.getInt(1); } 239 } 240 } 241 if (studentId == null) { return result; } 242 243 Integer progressTypeId = null; 244 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) { 245 ps.setString(1, progressTypeName); 246 try (ResultSet rs = ps.executeQuery()) { 247 if (rs.next()) { progressTypeId = rs.getInt(1); } 248 } 249 } 250 if (progressTypeId == null) { return result; } 251 252 // get parts in canonical order (by id) 253 List<Integer> partIds = new ArrayList<>(); 254 try (PreparedStatement ps = c.prepareStatement("SELECT id, code FROM AssessmentPart WHERE progress_type_id = ? ORDER BY id ASC")) { 255 ps.setInt(1, progressTypeId); 256 try (ResultSet rs = ps.executeQuery()) { 257 while (rs.next()) { partIds.add(rs.getInt("id")); } 258 } 259 } 260 261 // get latest session ids for this student and progress type 262 List<Integer> sessionIds = new ArrayList<>(); 263 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressSession WHERE student_id = ? AND progress_type_id = ? ORDER BY id DESC LIMIT ?")) { 264 ps.setInt(1, studentId); 265 ps.setInt(2, progressTypeId); 266 ps.setInt(3, limit); 267 try (ResultSet rs = ps.executeQuery()) { 268 while (rs.next()) { sessionIds.add(rs.getInt(1)); } 269 } 270 } 271 272 // For each session, fetch scores mapped to parts 273 for (Integer sid : sessionIds) { 274 Map<Integer, Integer> scoreByPart = new HashMap<>(); 275 try (PreparedStatement ps = c.prepareStatement("SELECT part_id, score FROM AssessmentResult WHERE session_id = ?")) { 276 ps.setInt(1, sid); 277 try (ResultSet rs = ps.executeQuery()) { 278 while (rs.next()) { 279 scoreByPart.put(rs.getInt("part_id"), rs.getInt("score")); 280 } 281 } 282 } 283 List<Integer> row = new ArrayList<>(); 284 for (Integer pid : partIds) { 285 Integer s = scoreByPart.get(pid); 286 row.add(s == null ? 0 : s); 287 } 288 result.add(row); 289 } 290 } 291 return result; 292 } 293 294 /** 295 * Simple, immutable holder for time-series assessment results. 296 * 297 * <p>Contains a chronologically ordered list of session {@code dates} 298 * and a parallel list of integer score rows. Each entry in {@code rows} 299 * corresponds to the parts for a progress type in canonical order. 300 */ 301 public static class ResultsWithDates { 302 /** 303 * Ordered session dates (oldest first). Can be empty when no sessions exist. 304 */ 305 public final java.util.List<java.time.LocalDate> dates; 306 307 /** 308 * Parallel rows of integer scores. Each inner list corresponds to the 309 * assessment parts for a single session in canonical part order. May be 310 * empty when there are no sessions. 311 */ 312 public final java.util.List<java.util.List<Integer>> rows; 313 314 /** 315 * Create a ResultsWithDates instance. 316 * 317 * @param dates ordered session dates (oldest-first) 318 * @param rows parallel list of score rows matching {@code dates} 319 */ 320 public ResultsWithDates(java.util.List<java.time.LocalDate> dates, java.util.List<java.util.List<Integer>> rows) { 321 this.dates = dates; 322 this.rows = rows; 323 } 324 } 325 326 /** 327 * Fetch the latest assessment rows along with their session dates. 328 * Rows and dates are ordered oldest-first to facilitate time series plotting. 329 * 330 * @param studentName student display name to filter results for 331 * @param progressTypeName progress type display name (e.g., "Braille") 332 * @param limit maximum number of recent sessions to return 333 * @return ResultsWithDates holding an ordered list of session dates and parallel rows of scores 334 * @throws SQLException on database errors 335 */ 336 public static ResultsWithDates fetchLatestAssessmentResultsWithDates(final String studentName, final String progressTypeName, final int limit) throws SQLException { 337 java.util.List<java.util.List<Integer>> result = new ArrayList<>(); 338 java.util.List<java.time.LocalDate> dates = new ArrayList<>(); 339 try (Connection c = getConnection()) { 340 Integer studentId = null; 341 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) { 342 ps.setString(1, studentName); 343 try (ResultSet rs = ps.executeQuery()) { 344 if (rs.next()) { 345 studentId = rs.getInt(1); 346 } 347 } 348 } 349 if (studentId == null) { return new ResultsWithDates(dates, result); } 350 351 Integer progressTypeId = null; 352 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) { 353 ps.setString(1, progressTypeName); 354 try (ResultSet rs = ps.executeQuery()) { 355 if (rs.next()) { 356 progressTypeId = rs.getInt(1); 357 } 358 } 359 } 360 if (progressTypeId == null) { 361 return new ResultsWithDates(dates, result); 362 } 363 364 // get parts in canonical order (by id) 365 java.util.List<Integer> partIds = new ArrayList<>(); 366 try (PreparedStatement ps = c.prepareStatement("SELECT id, code FROM AssessmentPart WHERE progress_type_id = ? ORDER BY id ASC")) { 367 ps.setInt(1, progressTypeId); 368 try (ResultSet rs = ps.executeQuery()) { 369 while (rs.next()) { 370 partIds.add(rs.getInt("id")); 371 } 372 } 373 } 374 375 // get latest session ids and dates for this student and progress type 376 java.util.List<java.lang.Integer> sessionIds = new ArrayList<>(); 377 java.util.List<java.time.LocalDate> sessionDates = new ArrayList<>(); 378 try (PreparedStatement ps = c.prepareStatement("SELECT id, date FROM ProgressSession WHERE student_id = ? AND progress_type_id = ? ORDER BY id DESC LIMIT ?")) { 379 ps.setInt(1, studentId); ps.setInt(2, progressTypeId); ps.setInt(3, limit); 380 try (ResultSet rs = ps.executeQuery()) { 381 while (rs.next()) { 382 sessionIds.add(rs.getInt("id")); 383 sessionDates.add(java.time.LocalDate.parse(rs.getString("date"))); 384 } 385 } 386 } 387 388 // We want chronological order (oldest first) 389 java.util.Collections.reverse(sessionIds); 390 java.util.Collections.reverse(sessionDates); 391 392 // For each session, fetch scores mapped to parts and append row 393 for (Integer sid : sessionIds) { 394 Map<Integer, Integer> scoreByPart = new HashMap<>(); 395 try (PreparedStatement ps = c.prepareStatement("SELECT part_id, score FROM AssessmentResult WHERE session_id = ?")) { 396 ps.setInt(1, sid); 397 try (ResultSet rs = ps.executeQuery()) { 398 while (rs.next()) { 399 scoreByPart.put(rs.getInt("part_id"), rs.getInt("score")); 400 } 401 } 402 } 403 java.util.List<Integer> row = new ArrayList<>(); 404 for (Integer pid : partIds) { 405 Integer s = scoreByPart.get(pid); 406 row.add(s == null ? 0 : s); 407 } 408 result.add(row); 409 } 410 dates.addAll(sessionDates); 411 } 412 return new ResultsWithDates(dates, result); 413 } 414 415 /** 416 * Insert a keyboarding-specific result linked to a ProgressSession. 417 * 418 * @param sessionId existing session id 419 * @param program program or curriculum name 420 * @param topic topic or lesson name 421 * @param speed words-per-minute 422 * @param accuracy accuracy percent 423 * @throws SQLException on database errors 424 */ 425 public static void insertKeyboardingResult(final int sessionId, final String program, final String topic, final int speed, final int accuracy) throws SQLException { 426 try (Connection c = getConnection()) { 427 try (PreparedStatement ps = c.prepareStatement("INSERT INTO KeyboardingResult(session_id, program, topic, speed, accuracy) VALUES (?, ?, ?, ?, ?)")) { 428 ps.setInt(1, sessionId); 429 ps.setString(2, program); 430 ps.setString(3, topic); 431 ps.setInt(4, speed); 432 ps.setInt(5, accuracy); 433 ps.executeUpdate(); 434 } 435 } 436 } 437 438 /** 439 * Save free-form notes for a given ProgressSession. 440 * 441 * @param sessionId progress session id 442 * @param notes free-form notes text 443 * @throws SQLException on database errors 444 */ 445 public static void saveSessionNotes(final int sessionId, final String notes) throws SQLException { 446 try (Connection c = getConnection()) { 447 try (PreparedStatement ps = c.prepareStatement("UPDATE ProgressSession SET notes = ? WHERE id = ?")) { 448 ps.setString(1, notes); 449 ps.setInt(2, sessionId); 450 ps.executeUpdate(); 451 } 452 } 453 } 454 455 /** 456 * Save structured contact log details for a given ProgressSession. This 457 * will insert or replace a single ContactLog row tied to the session. 458 * 459 * @param sessionId existing session id 460 * @param studentName student display name 461 * @param date session date as text 462 * @param guardianName guardian or parent name 463 * @param contactMethod method of contact (phone/email/etc) 464 * @param phoneNumber phone number string 465 * @param emailAddress email address string 466 * @param contactResponse short description of response 467 * @param contactGeneral general contact summary 468 * @param contactSpecific specific items discussed 469 * @param contactNotes free-form notes 470 * @throws SQLException on database errors 471 */ 472 public static void saveContactLog(final int sessionId, final String studentName, final String date, final String guardianName, final String contactMethod, final String phoneNumber, final String emailAddress, final String contactResponse, final String contactGeneral, final String contactSpecific, final String contactNotes) throws SQLException { 473 try (Connection c = getConnection()) { 474 try (PreparedStatement ps = c.prepareStatement("INSERT OR REPLACE INTO ContactLog(session_id, student_name, date, guardian_name, contact_method, phone_number, email_address, contact_response, contact_general, contact_specific, contact_notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") ) { 475 ps.setInt(1, sessionId); 476 ps.setString(2, studentName); 477 ps.setString(3, date); 478 ps.setString(4, guardianName); 479 ps.setString(5, contactMethod); 480 ps.setString(6, phoneNumber); 481 ps.setString(7, emailAddress); 482 ps.setString(8, contactResponse); 483 ps.setString(9, contactGeneral); 484 ps.setString(10, contactSpecific); 485 ps.setString(11, contactNotes); 486 ps.executeUpdate(); 487 } 488 } 489 } 490 491 /** 492 * Fetch the most recent ContactLog entry for the given student name. 493 * Returns a map of column names to string values, or null if none found. 494 * 495 * @param studentName student display name to search for 496 * @return map of contact log columns to values or null when not found 497 * @throws SQLException on database errors 498 */ 499 public static com.studentgui.apphelpers.dto.ContactPayload fetchLatestContactLog(final String studentName) throws SQLException { 500 try (Connection c = getConnection()) { 501 Integer studentId = null; 502 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) { 503 ps.setString(1, studentName); 504 try (ResultSet rs = ps.executeQuery()) { 505 if (rs.next()) { 506 studentId = rs.getInt(1); 507 } 508 } 509 } 510 if (studentId == null) { 511 return null; 512 } 513 514 // Find the latest session id for ProgressType 'ContactLog' 515 Integer ptId = null; 516 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) { 517 ps.setString(1, "ContactLog"); 518 try (ResultSet rs = ps.executeQuery()) { 519 if (rs.next()) { 520 ptId = rs.getInt(1); 521 } 522 } 523 } 524 if (ptId == null) { 525 return null; 526 } 527 528 Integer sessionId = null; 529 try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressSession WHERE student_id = ? AND progress_type_id = ? ORDER BY id DESC LIMIT 1")) { 530 ps.setInt(1, studentId); 531 ps.setInt(2, ptId); 532 try (ResultSet rs = ps.executeQuery()) { 533 if (rs.next()) { 534 sessionId = rs.getInt(1); 535 } 536 } 537 } 538 if (sessionId == null) { 539 return null; 540 } 541 542 try (PreparedStatement ps = c.prepareStatement("SELECT student_name, date, guardian_name, contact_method, phone_number, email_address, contact_response, contact_general, contact_specific, contact_notes FROM ContactLog WHERE session_id = ? ORDER BY id DESC LIMIT 1")) { 543 ps.setInt(1, sessionId); 544 try (ResultSet rs = ps.executeQuery()) { 545 if (rs.next()) { 546 com.studentgui.apphelpers.dto.ContactPayload p = new com.studentgui.apphelpers.dto.ContactPayload( 547 sessionId, 548 rs.getString("guardian_name"), 549 rs.getString("contact_method"), 550 rs.getString("phone_number"), 551 rs.getString("email_address"), 552 rs.getString("contact_response"), 553 rs.getString("contact_general"), 554 rs.getString("contact_specific"), 555 rs.getString("contact_notes") 556 ); 557 return p; 558 } 559 } 560 } 561 return null; 562 } 563 } 564 565}