PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
study_repository.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
14
15#include <chrono>
16#include <ctime>
17#include <iomanip>
18#include <set>
19#include <sstream>
20
21#ifdef PACS_WITH_DATABASE_SYSTEM
22
23#include <database/query_builder.h>
25
26namespace kcenon::pacs::storage {
27
28using kcenon::common::make_error;
29using kcenon::common::ok;
30
31// =============================================================================
32// Constructor
33// =============================================================================
34
35study_repository::study_repository(std::shared_ptr<pacs_database_adapter> db)
36 : base_repository(std::move(db), "studies", "study_pk") {}
37
38// =============================================================================
39// Timestamp Helpers
40// =============================================================================
41
42auto study_repository::parse_timestamp(const std::string& str) const
43 -> std::chrono::system_clock::time_point {
44 if (str.empty()) {
45 return {};
46 }
47
48 std::tm tm{};
49 if (std::sscanf(str.c_str(), "%d-%d-%d %d:%d:%d", &tm.tm_year, &tm.tm_mon,
50 &tm.tm_mday, &tm.tm_hour, &tm.tm_min, &tm.tm_sec) != 6) {
51 return {};
52 }
53
54 tm.tm_year -= 1900;
55 tm.tm_mon -= 1;
56
57#ifdef _WIN32
58 auto time = _mkgmtime(&tm);
59#else
60 auto time = timegm(&tm);
61#endif
62
63 return std::chrono::system_clock::from_time_t(time);
64}
65
66auto study_repository::format_timestamp(
67 std::chrono::system_clock::time_point tp) const -> std::string {
68 if (tp == std::chrono::system_clock::time_point{}) {
69 return "";
70 }
71
72 auto time = std::chrono::system_clock::to_time_t(tp);
73 std::tm tm{};
74#ifdef _WIN32
75 gmtime_s(&tm, &time);
76#else
77 gmtime_r(&time, &tm);
78#endif
79
80 char buf[32];
81 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
82 return buf;
83}
84
85// =============================================================================
86// Wildcard Pattern Helper
87// =============================================================================
88
89auto study_repository::to_like_pattern(std::string_view pattern)
90 -> std::string {
91 std::string result;
92 result.reserve(pattern.size());
93
94 for (char c : pattern) {
95 if (c == '*') {
96 result += '%';
97 } else if (c == '?') {
98 result += '_';
99 } else if (c == '%' || c == '_') {
100 result += '\\';
101 result += c;
102 } else {
103 result += c;
104 }
105 }
106
107 return result;
108}
109
110// =============================================================================
111// Domain-Specific Operations
112// =============================================================================
113
114auto study_repository::upsert_study(int64_t patient_pk,
115 std::string_view study_uid,
116 std::string_view study_id,
117 std::string_view study_date,
118 std::string_view study_time,
119 std::string_view accession_number,
120 std::string_view referring_physician,
121 std::string_view study_description)
122 -> Result<int64_t> {
123 study_record record;
124 record.patient_pk = patient_pk;
125 record.study_uid = std::string(study_uid);
126 record.study_id = std::string(study_id);
127 record.study_date = std::string(study_date);
128 record.study_time = std::string(study_time);
129 record.accession_number = std::string(accession_number);
130 record.referring_physician = std::string(referring_physician);
131 record.study_description = std::string(study_description);
132 return upsert_study(record);
133}
134
135auto study_repository::upsert_study(const study_record& record)
136 -> Result<int64_t> {
137 if (record.study_uid.empty()) {
138 return make_error<int64_t>(-1, "Study Instance UID is required",
139 "storage");
140 }
141
142 if (record.study_uid.length() > 64) {
143 return make_error<int64_t>(
144 -1, "Study Instance UID exceeds maximum length of 64 characters",
145 "storage");
146 }
147
148 if (record.patient_pk <= 0) {
149 return make_error<int64_t>(-1, "Valid patient_pk is required",
150 "storage");
151 }
152
153 if (!db() || !db()->is_connected()) {
154 return make_error<int64_t>(-1, "Database not connected", "storage");
155 }
156
157 auto builder = query_builder();
158
159 // Check if study exists
160 auto check_sql = builder.select(std::vector<std::string>{"study_pk"})
161 .from("studies")
162 .where("study_uid", "=", record.study_uid)
163 .build();
164
165 auto check_result = db()->select(check_sql);
166 if (check_result.is_err()) {
167 return make_error<int64_t>(
168 -1,
169 kcenon::pacs::compat::format("Failed to check study existence: {}",
170 check_result.error().message),
171 "storage");
172 }
173
174 if (!check_result.value().empty()) {
175 // Study exists - update
176 auto existing = find_study(record.study_uid);
177 if (!existing.has_value()) {
178 return make_error<int64_t>(
179 -1, "Study exists but could not retrieve record", "storage");
180 }
181
182 auto update_builder = db()->create_query_builder();
183 auto update_sql =
184 update_builder.update("studies")
185 .set({{"patient_pk", std::to_string(record.patient_pk)},
186 {"study_id", record.study_id},
187 {"study_date", record.study_date},
188 {"study_time", record.study_time},
189 {"accession_number", record.accession_number},
190 {"referring_physician", record.referring_physician},
191 {"study_description", record.study_description},
192 {"updated_at", "datetime('now')"}})
193 .where("study_uid", "=", record.study_uid)
194 .build();
195
196 auto update_result = db()->update(update_sql);
197 if (update_result.is_err()) {
198 return make_error<int64_t>(
199 -1,
200 kcenon::pacs::compat::format("Failed to update study: {}",
201 update_result.error().message),
202 "storage");
203 }
204 return existing->pk;
205 } else {
206 // Study doesn't exist - insert
207 auto insert_builder = db()->create_query_builder();
208 auto insert_sql =
209 insert_builder.insert_into("studies")
210 .values({{"patient_pk", std::to_string(record.patient_pk)},
211 {"study_uid", record.study_uid},
212 {"study_id", record.study_id},
213 {"study_date", record.study_date},
214 {"study_time", record.study_time},
215 {"accession_number", record.accession_number},
216 {"referring_physician", record.referring_physician},
217 {"study_description", record.study_description}})
218 .build();
219
220 auto insert_result = db()->insert(insert_sql);
221 if (insert_result.is_err()) {
222 return make_error<int64_t>(
223 -1,
224 kcenon::pacs::compat::format("Failed to insert study: {}",
225 insert_result.error().message),
226 "storage");
227 }
228
229 auto inserted = find_study(record.study_uid);
230 if (!inserted.has_value()) {
231 return make_error<int64_t>(
232 -1, "Study inserted but could not retrieve record", "storage");
233 }
234 return inserted->pk;
235 }
236}
237
238auto study_repository::find_study(std::string_view study_uid)
239 -> std::optional<study_record> {
240 if (!db() || !db()->is_connected()) {
241 return std::nullopt;
242 }
243
244 auto builder = query_builder();
245 auto select_sql =
246 builder
247 .select(select_columns())
248 .from("studies")
249 .where("study_uid", "=", std::string(study_uid))
250 .build();
251
252 auto result = db()->select(select_sql);
253 if (result.is_err() || result.value().empty()) {
254 return std::nullopt;
255 }
256
257 return map_row_to_entity(result.value()[0]);
258}
259
260auto study_repository::find_study_by_pk(int64_t pk)
261 -> std::optional<study_record> {
262 if (!db() || !db()->is_connected()) {
263 return std::nullopt;
264 }
265
266 auto builder = query_builder();
267 auto select_sql =
268 builder
269 .select(select_columns())
270 .from("studies")
271 .where("study_pk", "=", pk)
272 .build();
273
274 auto result = db()->select(select_sql);
275 if (result.is_err() || result.value().empty()) {
276 return std::nullopt;
277 }
278
279 return map_row_to_entity(result.value()[0]);
280}
281
282auto study_repository::search_studies(const study_query& query)
283 -> Result<std::vector<study_record>> {
284 if (!db() || !db()->is_connected()) {
285 return make_error<std::vector<study_record>>(
286 -1, "Database not connected", "storage");
287 }
288
289 // Build SQL query with JOIN if patient filters are present
290 std::string sql;
291 std::vector<std::string> where_clauses;
292
293 if (query.patient_id.has_value() || query.patient_name.has_value()) {
294 sql = R"(
295 SELECT s.study_pk, s.patient_pk, s.study_uid, s.study_id, s.study_date,
296 s.study_time, s.accession_number, s.referring_physician,
297 s.study_description, s.modalities_in_study, s.num_series,
298 s.num_instances, s.created_at, s.updated_at
299 FROM studies s
300 JOIN patients p ON s.patient_pk = p.patient_pk
301 )";
302
303 if (query.patient_id.has_value()) {
304 where_clauses.push_back(
305 kcenon::pacs::compat::format("p.patient_id LIKE '{}'",
306 to_like_pattern(*query.patient_id)));
307 }
308
309 if (query.patient_name.has_value()) {
310 where_clauses.push_back(
311 kcenon::pacs::compat::format("p.patient_name LIKE '{}'",
312 to_like_pattern(*query.patient_name)));
313 }
314 } else {
315 sql = R"(
316 SELECT study_pk, patient_pk, study_uid, study_id, study_date,
317 study_time, accession_number, referring_physician,
318 study_description, modalities_in_study, num_series,
319 num_instances, created_at, updated_at
320 FROM studies
321 )";
322 }
323
324 // Add study-specific filters
325 std::string prefix = (query.patient_id.has_value() || query.patient_name.has_value()) ? "s." : "";
326
327 if (query.study_uid.has_value()) {
328 where_clauses.push_back(
329 kcenon::pacs::compat::format("{}study_uid = '{}'", prefix, *query.study_uid));
330 }
331
332 if (query.study_id.has_value()) {
333 where_clauses.push_back(
334 kcenon::pacs::compat::format("{}study_id LIKE '{}'", prefix,
335 to_like_pattern(*query.study_id)));
336 }
337
338 if (query.study_date.has_value()) {
339 where_clauses.push_back(
340 kcenon::pacs::compat::format("{}study_date = '{}'", prefix, *query.study_date));
341 }
342
343 if (query.study_date_from.has_value()) {
344 where_clauses.push_back(
345 kcenon::pacs::compat::format("{}study_date >= '{}'", prefix, *query.study_date_from));
346 }
347
348 if (query.study_date_to.has_value()) {
349 where_clauses.push_back(
350 kcenon::pacs::compat::format("{}study_date <= '{}'", prefix, *query.study_date_to));
351 }
352
353 if (query.accession_number.has_value()) {
354 where_clauses.push_back(
355 kcenon::pacs::compat::format("{}accession_number LIKE '{}'", prefix,
356 to_like_pattern(*query.accession_number)));
357 }
358
359 if (query.referring_physician.has_value()) {
360 where_clauses.push_back(
361 kcenon::pacs::compat::format("{}referring_physician LIKE '{}'", prefix,
362 to_like_pattern(*query.referring_physician)));
363 }
364
365 if (query.study_description.has_value()) {
366 where_clauses.push_back(
367 kcenon::pacs::compat::format("{}study_description LIKE '{}'", prefix,
368 to_like_pattern(*query.study_description)));
369 }
370
371 if (query.modality.has_value()) {
372 const auto& mod = *query.modality;
373 where_clauses.push_back(kcenon::pacs::compat::format(
374 "({}modalities_in_study = '{}' OR "
375 "{}modalities_in_study LIKE '{}\\%' OR "
376 "{}modalities_in_study LIKE '%\\{}' OR "
377 "{}modalities_in_study LIKE '%\\{}\\%')",
378 prefix, mod, prefix, mod, prefix, mod, prefix, mod));
379 }
380
381 // Build WHERE clause
382 if (!where_clauses.empty()) {
383 sql += " WHERE " + where_clauses[0];
384 for (size_t i = 1; i < where_clauses.size(); ++i) {
385 sql += " AND " + where_clauses[i];
386 }
387 }
388
389 // Add ORDER BY
390 sql += kcenon::pacs::compat::format(" ORDER BY {}study_date DESC, {}study_time DESC",
391 prefix, prefix);
392
393 if (query.limit > 0) {
394 sql += kcenon::pacs::compat::format(" LIMIT {}", query.limit);
395 }
396
397 if (query.offset > 0) {
398 sql += kcenon::pacs::compat::format(" OFFSET {}", query.offset);
399 }
400
401 auto result = db()->select(sql);
402 if (result.is_err()) {
403 return make_error<std::vector<study_record>>(
404 -1,
405 kcenon::pacs::compat::format("Query failed: {}", result.error().message),
406 "storage");
407 }
408
409 std::vector<study_record> results;
410 results.reserve(result.value().size());
411 for (const auto& row : result.value()) {
412 results.push_back(map_row_to_entity(row));
413 }
414 return ok(std::move(results));
415}
416
417auto study_repository::delete_study(std::string_view study_uid) -> VoidResult {
418 if (!db() || !db()->is_connected()) {
419 return make_error<std::monostate>(-1, "Database not connected", "storage");
420 }
421
422 auto builder = query_builder();
423 auto delete_sql = builder.delete_from("studies")
424 .where("study_uid", "=", std::string(study_uid))
425 .build();
426
427 auto result = db()->remove(delete_sql);
428 if (result.is_err()) {
429 return make_error<std::monostate>(
430 -1,
431 kcenon::pacs::compat::format("Failed to delete study: {}",
432 result.error().message),
433 "storage");
434 }
435 return ok();
436}
437
438auto study_repository::study_count() -> Result<size_t> {
439 if (!db() || !db()->is_connected()) {
440 return make_error<size_t>(-1, "Database not connected", "storage");
441 }
442
443 auto result = db()->select("SELECT COUNT(*) AS count FROM studies;");
444 if (result.is_err()) {
445 return make_error<size_t>(
446 -1,
447 kcenon::pacs::compat::format("Query failed: {}", result.error().message),
448 "storage");
449 }
450
451 if (!result.value().empty()) {
452 const auto& row = result.value()[0];
453 auto it = row.find("count");
454 if (it == row.end() && !row.empty()) {
455 it = row.begin();
456 }
457
458 if (it != row.end() && !it->second.empty()) {
459 try {
460 return ok(static_cast<size_t>(std::stoll(it->second)));
461 } catch (...) {
462 return ok(static_cast<size_t>(0));
463 }
464 }
465 }
466 return ok(static_cast<size_t>(0));
467}
468
469auto study_repository::study_count_for_patient(int64_t patient_pk)
470 -> Result<size_t> {
471 if (!db() || !db()->is_connected()) {
472 return make_error<size_t>(-1, "Database not connected", "storage");
473 }
474
475 auto result = db()->select(kcenon::pacs::compat::format(
476 "SELECT COUNT(*) AS count FROM studies WHERE patient_pk = {};",
477 patient_pk));
478 if (result.is_err()) {
479 return make_error<size_t>(
480 -1,
481 kcenon::pacs::compat::format("Query failed: {}", result.error().message),
482 "storage");
483 }
484
485 if (!result.value().empty()) {
486 const auto& row = result.value()[0];
487 auto it = row.find("count");
488 if (it == row.end() && !row.empty()) {
489 it = row.begin();
490 }
491
492 if (it != row.end() && !it->second.empty()) {
493 try {
494 return ok(static_cast<size_t>(std::stoll(it->second)));
495 } catch (...) {
496 return ok(static_cast<size_t>(0));
497 }
498 }
499 }
500 return ok(static_cast<size_t>(0));
501}
502
503auto study_repository::update_modalities_in_study(int64_t study_pk)
504 -> VoidResult {
505 if (!db() || !db()->is_connected()) {
506 return make_error<std::monostate>(-1, "Database not connected", "storage");
507 }
508
509 auto update_sql = kcenon::pacs::compat::format(
510 R"(UPDATE studies
511SET modalities_in_study = (
512 SELECT GROUP_CONCAT(modality, '\')
513 FROM (
514 SELECT DISTINCT modality
515 FROM series
516 WHERE study_pk = {} AND modality IS NOT NULL AND modality != ''
517 )
518 ),
519 updated_at = datetime('now')
520WHERE study_pk = {};)",
521 study_pk, study_pk);
522
523 auto update_result = db()->update(update_sql);
524 if (update_result.is_err()) {
525 return make_error<std::monostate>(
526 -1,
527 kcenon::pacs::compat::format("Failed to update modalities: {}",
528 update_result.error().message),
529 "storage");
530 }
531 return ok();
532}
533
534// =============================================================================
535// base_repository Overrides
536// =============================================================================
537
538auto study_repository::map_row_to_entity(const database_row& row) const
539 -> study_record {
540 study_record record;
541
542 auto get_str = [&row](const std::string& key) -> std::string {
543 auto it = row.find(key);
544 return (it != row.end()) ? it->second : std::string{};
545 };
546
547 auto get_int64 = [&row](const std::string& key) -> int64_t {
548 auto it = row.find(key);
549 if (it != row.end() && !it->second.empty()) {
550 try {
551 return std::stoll(it->second);
552 } catch (...) {
553 return 0;
554 }
555 }
556 return 0;
557 };
558
559 auto get_optional_int = [&row](const std::string& key) -> std::optional<int> {
560 auto it = row.find(key);
561 if (it != row.end() && !it->second.empty()) {
562 try {
563 return std::stoi(it->second);
564 } catch (...) {
565 return std::nullopt;
566 }
567 }
568 return std::nullopt;
569 };
570
571 record.pk = get_int64("study_pk");
572 record.patient_pk = get_int64("patient_pk");
573 record.study_uid = get_str("study_uid");
574 record.study_id = get_str("study_id");
575 record.study_date = get_str("study_date");
576 record.study_time = get_str("study_time");
577 record.accession_number = get_str("accession_number");
578 record.referring_physician = get_str("referring_physician");
579 record.study_description = get_str("study_description");
580 record.modalities_in_study = get_str("modalities_in_study");
581 record.num_instances = get_optional_int("num_instances").value_or(0);
582 record.num_series = get_optional_int("num_series").value_or(0);
583
584 auto created_at_str = get_str("created_at");
585 if (!created_at_str.empty()) {
586 record.created_at = parse_timestamp(created_at_str);
587 }
588
589 auto updated_at_str = get_str("updated_at");
590 if (!updated_at_str.empty()) {
591 record.updated_at = parse_timestamp(updated_at_str);
592 }
593
594 return record;
595}
596
597auto study_repository::entity_to_row(const study_record& entity) const
598 -> std::map<std::string, database_value> {
599 std::map<std::string, database_value> row;
600
601 row["patient_pk"] = std::to_string(entity.patient_pk);
602 row["study_uid"] = entity.study_uid;
603 row["study_id"] = entity.study_id;
604 row["study_date"] = entity.study_date;
605 row["study_time"] = entity.study_time;
606 row["accession_number"] = entity.accession_number;
607 row["referring_physician"] = entity.referring_physician;
608 row["study_description"] = entity.study_description;
609 row["modalities_in_study"] = entity.modalities_in_study;
610 row["num_series"] = std::to_string(entity.num_series);
611 row["num_instances"] = std::to_string(entity.num_instances);
612
613 auto now = std::chrono::system_clock::now();
614 if (entity.created_at != std::chrono::system_clock::time_point{}) {
615 row["created_at"] = format_timestamp(entity.created_at);
616 } else {
617 row["created_at"] = format_timestamp(now);
618 }
619
620 if (entity.updated_at != std::chrono::system_clock::time_point{}) {
621 row["updated_at"] = format_timestamp(entity.updated_at);
622 } else {
623 row["updated_at"] = format_timestamp(now);
624 }
625
626 return row;
627}
628
629auto study_repository::get_pk(const study_record& entity) const -> int64_t {
630 return entity.pk;
631}
632
633auto study_repository::has_pk(const study_record& entity) const -> bool {
634 return entity.pk > 0;
635}
636
637auto study_repository::select_columns() const -> std::vector<std::string> {
638 return {"study_pk", "patient_pk", "study_uid",
639 "study_id", "study_date", "study_time",
640 "accession_number", "referring_physician", "study_description",
641 "modalities_in_study", "num_series", "num_instances",
642 "created_at", "updated_at"};
643}
644
645} // namespace kcenon::pacs::storage
646
647#else // !PACS_WITH_DATABASE_SYSTEM
648
649// =============================================================================
650// Legacy SQLite Implementation
651// =============================================================================
652
655#include <sqlite3.h>
656
657namespace kcenon::pacs::storage {
658
659using kcenon::common::make_error;
660using kcenon::common::ok;
661using namespace kcenon::pacs::error_codes;
662
663namespace {
664
665auto parse_datetime(const char* str)
666 -> std::chrono::system_clock::time_point {
667 if (!str || *str == '\0') {
668 return std::chrono::system_clock::now();
669 }
670
671 std::tm tm{};
672 std::istringstream ss(str);
673 ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
674
675 if (ss.fail()) {
676 return std::chrono::system_clock::now();
677 }
678
679 return std::chrono::system_clock::from_time_t(std::mktime(&tm));
680}
681
682auto get_text(sqlite3_stmt* stmt, int col) -> std::string {
683 const auto* text =
684 reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
685 return text ? std::string(text) : std::string{};
686}
687
688} // namespace
689
690study_repository::study_repository(sqlite3* db) : db_(db) {}
691
693
695
696auto study_repository::operator=(study_repository&&) noexcept
697 -> study_repository& = default;
698
699auto study_repository::to_like_pattern(std::string_view pattern)
700 -> std::string {
701 std::string result;
702 result.reserve(pattern.size());
703
704 for (char c : pattern) {
705 if (c == '*') {
706 result += '%';
707 } else if (c == '?') {
708 result += '_';
709 } else if (c == '%' || c == '_') {
710 result += '\\';
711 result += c;
712 } else {
713 result += c;
714 }
715 }
716
717 return result;
718}
719
720auto study_repository::parse_study_row(void* stmt_ptr) const -> study_record {
721 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
722 study_record record;
723
724 record.pk = sqlite3_column_int64(stmt, 0);
725 record.patient_pk = sqlite3_column_int64(stmt, 1);
726 record.study_uid = get_text(stmt, 2);
727 record.study_id = get_text(stmt, 3);
728 record.study_date = get_text(stmt, 4);
729 record.study_time = get_text(stmt, 5);
730 record.accession_number = get_text(stmt, 6);
731 record.referring_physician = get_text(stmt, 7);
732 record.study_description = get_text(stmt, 8);
733 record.modalities_in_study = get_text(stmt, 9);
734 record.num_series = sqlite3_column_int(stmt, 10);
735 record.num_instances = sqlite3_column_int(stmt, 11);
736
737 auto created_str = get_text(stmt, 12);
738 record.created_at = parse_datetime(created_str.c_str());
739
740 auto updated_str = get_text(stmt, 13);
741 record.updated_at = parse_datetime(updated_str.c_str());
742
743 return record;
744}
745
746auto study_repository::upsert_study(int64_t patient_pk,
747 std::string_view study_uid,
748 std::string_view study_id,
749 std::string_view study_date,
750 std::string_view study_time,
751 std::string_view accession_number,
752 std::string_view referring_physician,
753 std::string_view study_description)
754 -> Result<int64_t> {
755 study_record record;
756 record.patient_pk = patient_pk;
757 record.study_uid = std::string(study_uid);
758 record.study_id = std::string(study_id);
759 record.study_date = std::string(study_date);
760 record.study_time = std::string(study_time);
761 record.accession_number = std::string(accession_number);
762 record.referring_physician = std::string(referring_physician);
763 record.study_description = std::string(study_description);
764 return upsert_study(record);
765}
766
768 -> Result<int64_t> {
769 if (record.study_uid.empty()) {
770 return make_error<int64_t>(-1, "Study Instance UID is required",
771 "storage");
772 }
773
774 if (record.study_uid.length() > 64) {
775 return make_error<int64_t>(
776 -1, "Study Instance UID exceeds maximum length of 64 characters",
777 "storage");
778 }
779
780 if (record.patient_pk <= 0) {
781 return make_error<int64_t>(-1, "Valid patient_pk is required",
782 "storage");
783 }
784
785 const char* sql = R"(
786 INSERT INTO studies (
787 patient_pk, study_uid, study_id, study_date, study_time,
788 accession_number, referring_physician, study_description,
789 updated_at
790 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
791 ON CONFLICT(study_uid) DO UPDATE SET
792 patient_pk = excluded.patient_pk,
793 study_id = excluded.study_id,
794 study_date = excluded.study_date,
795 study_time = excluded.study_time,
796 accession_number = excluded.accession_number,
797 referring_physician = excluded.referring_physician,
798 study_description = excluded.study_description,
799 updated_at = datetime('now')
800 RETURNING study_pk;
801 )";
802
803 sqlite3_stmt* stmt = nullptr;
804 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
805 if (rc != SQLITE_OK) {
806 return make_error<int64_t>(
807 rc,
808 kcenon::pacs::compat::format("Failed to prepare statement: {}", sqlite3_errmsg(db_)),
809 "storage");
810 }
811
812 sqlite3_bind_int64(stmt, 1, record.patient_pk);
813 sqlite3_bind_text(stmt, 2, record.study_uid.c_str(), -1, SQLITE_TRANSIENT);
814 sqlite3_bind_text(stmt, 3, record.study_id.c_str(), -1, SQLITE_TRANSIENT);
815 sqlite3_bind_text(stmt, 4, record.study_date.c_str(), -1, SQLITE_TRANSIENT);
816 sqlite3_bind_text(stmt, 5, record.study_time.c_str(), -1, SQLITE_TRANSIENT);
817 sqlite3_bind_text(stmt, 6, record.accession_number.c_str(), -1,
818 SQLITE_TRANSIENT);
819 sqlite3_bind_text(stmt, 7, record.referring_physician.c_str(), -1,
820 SQLITE_TRANSIENT);
821 sqlite3_bind_text(stmt, 8, record.study_description.c_str(), -1,
822 SQLITE_TRANSIENT);
823
824 rc = sqlite3_step(stmt);
825 if (rc != SQLITE_ROW) {
826 auto error_msg = sqlite3_errmsg(db_);
827 sqlite3_finalize(stmt);
828 return make_error<int64_t>(
829 rc, kcenon::pacs::compat::format("Failed to upsert study: {}", error_msg),
830 "storage");
831 }
832
833 auto pk = sqlite3_column_int64(stmt, 0);
834 sqlite3_finalize(stmt);
835
836 return pk;
837}
838
839auto study_repository::find_study(std::string_view study_uid) const
840 -> std::optional<study_record> {
841 const char* sql = R"(
842 SELECT study_pk, patient_pk, study_uid, study_id, study_date,
843 study_time, accession_number, referring_physician,
844 study_description, modalities_in_study, num_series,
845 num_instances, created_at, updated_at
846 FROM studies
847 WHERE study_uid = ?;
848 )";
849
850 sqlite3_stmt* stmt = nullptr;
851 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
852 if (rc != SQLITE_OK) {
853 return std::nullopt;
854 }
855
856 sqlite3_bind_text(stmt, 1, study_uid.data(),
857 static_cast<int>(study_uid.size()), SQLITE_TRANSIENT);
858
859 rc = sqlite3_step(stmt);
860 if (rc != SQLITE_ROW) {
861 sqlite3_finalize(stmt);
862 return std::nullopt;
863 }
864
865 auto record = parse_study_row(stmt);
866 sqlite3_finalize(stmt);
867
868 return record;
869}
870
872 -> std::optional<study_record> {
873 const char* sql = R"(
874 SELECT study_pk, patient_pk, study_uid, study_id, study_date,
875 study_time, accession_number, referring_physician,
876 study_description, modalities_in_study, num_series,
877 num_instances, created_at, updated_at
878 FROM studies
879 WHERE study_pk = ?;
880 )";
881
882 sqlite3_stmt* stmt = nullptr;
883 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
884 if (rc != SQLITE_OK) {
885 return std::nullopt;
886 }
887
888 sqlite3_bind_int64(stmt, 1, pk);
889
890 rc = sqlite3_step(stmt);
891 if (rc != SQLITE_ROW) {
892 sqlite3_finalize(stmt);
893 return std::nullopt;
894 }
895
896 auto record = parse_study_row(stmt);
897 sqlite3_finalize(stmt);
898
899 return record;
900}
901
904 std::vector<study_record> results;
905
906 std::string sql = R"(
907 SELECT s.study_pk, s.patient_pk, s.study_uid, s.study_id, s.study_date,
908 s.study_time, s.accession_number, s.referring_physician,
909 s.study_description, s.modalities_in_study, s.num_series,
910 s.num_instances, s.created_at, s.updated_at
911 FROM studies s
912 JOIN patients p ON s.patient_pk = p.patient_pk
913 WHERE 1=1
914 )";
915
916 std::vector<std::string> params;
917
918 if (query.patient_id.has_value()) {
919 sql += " AND p.patient_id LIKE ?";
920 params.push_back(to_like_pattern(*query.patient_id));
921 }
922
923 if (query.patient_name.has_value()) {
924 sql += " AND p.patient_name LIKE ?";
925 params.push_back(to_like_pattern(*query.patient_name));
926 }
927
928 if (query.study_uid.has_value()) {
929 sql += " AND s.study_uid = ?";
930 params.push_back(*query.study_uid);
931 }
932
933 if (query.study_id.has_value()) {
934 sql += " AND s.study_id LIKE ?";
935 params.push_back(to_like_pattern(*query.study_id));
936 }
937
938 if (query.study_date.has_value()) {
939 sql += " AND s.study_date = ?";
940 params.push_back(*query.study_date);
941 }
942
943 if (query.study_date_from.has_value()) {
944 sql += " AND s.study_date >= ?";
945 params.push_back(*query.study_date_from);
946 }
947
948 if (query.study_date_to.has_value()) {
949 sql += " AND s.study_date <= ?";
950 params.push_back(*query.study_date_to);
951 }
952
953 if (query.accession_number.has_value()) {
954 sql += " AND s.accession_number LIKE ?";
955 params.push_back(to_like_pattern(*query.accession_number));
956 }
957
958 if (query.modality.has_value()) {
959 sql += " AND (s.modalities_in_study = ? OR "
960 "s.modalities_in_study LIKE ? OR "
961 "s.modalities_in_study LIKE ? OR "
962 "s.modalities_in_study LIKE ?)";
963 params.push_back(*query.modality);
964 params.push_back(*query.modality + "\\%");
965 params.push_back("%\\" + *query.modality);
966 params.push_back("%\\" + *query.modality + "\\%");
967 }
968
969 if (query.referring_physician.has_value()) {
970 sql += " AND s.referring_physician LIKE ?";
971 params.push_back(to_like_pattern(*query.referring_physician));
972 }
973
974 if (query.study_description.has_value()) {
975 sql += " AND s.study_description LIKE ?";
976 params.push_back(to_like_pattern(*query.study_description));
977 }
978
979 sql += " ORDER BY s.study_date DESC, s.study_time DESC";
980
981 if (query.limit > 0) {
982 sql += kcenon::pacs::compat::format(" LIMIT {}", query.limit);
983 }
984
985 if (query.offset > 0) {
986 sql += kcenon::pacs::compat::format(" OFFSET {}", query.offset);
987 }
988
989 sqlite3_stmt* stmt = nullptr;
990 auto rc = sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
991 if (rc != SQLITE_OK) {
992 return make_error<std::vector<study_record>>(
994 kcenon::pacs::compat::format("Failed to prepare query: {}", sqlite3_errmsg(db_)),
995 "storage");
996 }
997
998 for (size_t i = 0; i < params.size(); ++i) {
999 sqlite3_bind_text(stmt, static_cast<int>(i + 1), params[i].c_str(), -1,
1000 SQLITE_TRANSIENT);
1001 }
1002
1003 while (sqlite3_step(stmt) == SQLITE_ROW) {
1004 results.push_back(parse_study_row(stmt));
1005 }
1006
1007 sqlite3_finalize(stmt);
1008 return ok(std::move(results));
1009}
1010
1011auto study_repository::delete_study(std::string_view study_uid) -> VoidResult {
1012 const char* sql = "DELETE FROM studies WHERE study_uid = ?;";
1013
1014 sqlite3_stmt* stmt = nullptr;
1015 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1016 if (rc != SQLITE_OK) {
1017 return make_error<std::monostate>(
1018 rc,
1019 kcenon::pacs::compat::format("Failed to prepare delete: {}", sqlite3_errmsg(db_)),
1020 "storage");
1021 }
1022
1023 sqlite3_bind_text(stmt, 1, study_uid.data(),
1024 static_cast<int>(study_uid.size()), SQLITE_TRANSIENT);
1025
1026 rc = sqlite3_step(stmt);
1027 sqlite3_finalize(stmt);
1028
1029 if (rc != SQLITE_DONE) {
1030 return make_error<std::monostate>(
1031 rc, kcenon::pacs::compat::format("Failed to delete study: {}", sqlite3_errmsg(db_)),
1032 "storage");
1033 }
1034
1035 return ok();
1036}
1037
1038auto study_repository::study_count() const -> Result<size_t> {
1039 const char* sql = "SELECT COUNT(*) FROM studies;";
1040
1041 sqlite3_stmt* stmt = nullptr;
1042 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1043 if (rc != SQLITE_OK) {
1044 return make_error<size_t>(
1046 kcenon::pacs::compat::format("Failed to prepare query: {}", sqlite3_errmsg(db_)),
1047 "storage");
1048 }
1049
1050 size_t count = 0;
1051 if (sqlite3_step(stmt) == SQLITE_ROW) {
1052 count = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1053 }
1054
1055 sqlite3_finalize(stmt);
1056 return ok(count);
1057}
1058
1059auto study_repository::study_count_for_patient(int64_t patient_pk) const
1060 -> Result<size_t> {
1061 const char* sql = R"(
1062 SELECT COUNT(*) FROM studies
1063 WHERE patient_pk = ?;
1064 )";
1065
1066 sqlite3_stmt* stmt = nullptr;
1067 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1068 if (rc != SQLITE_OK) {
1069 return make_error<size_t>(
1071 kcenon::pacs::compat::format("Failed to prepare query: {}", sqlite3_errmsg(db_)),
1072 "storage");
1073 }
1074
1075 sqlite3_bind_int64(stmt, 1, patient_pk);
1076
1077 size_t count = 0;
1078 if (sqlite3_step(stmt) == SQLITE_ROW) {
1079 count = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1080 }
1081
1082 sqlite3_finalize(stmt);
1083 return ok(count);
1084}
1085
1087 -> VoidResult {
1088 const char* sql = R"(
1089 UPDATE studies
1090 SET modalities_in_study = (
1091 SELECT GROUP_CONCAT(modality, '\')
1092 FROM (
1093 SELECT DISTINCT modality FROM series
1094 WHERE study_pk = ? AND modality IS NOT NULL AND modality != ''
1095 )
1096 ),
1097 updated_at = datetime('now')
1098 WHERE study_pk = ?;
1099 )";
1100
1101 sqlite3_stmt* stmt = nullptr;
1102 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1103 if (rc != SQLITE_OK) {
1104 return make_error<std::monostate>(
1105 rc,
1106 kcenon::pacs::compat::format("Failed to prepare update: {}", sqlite3_errmsg(db_)),
1107 "storage");
1108 }
1109
1110 sqlite3_bind_int64(stmt, 1, study_pk);
1111 sqlite3_bind_int64(stmt, 2, study_pk);
1112
1113 rc = sqlite3_step(stmt);
1114 sqlite3_finalize(stmt);
1115
1116 if (rc != SQLITE_DONE) {
1117 return make_error<std::monostate>(
1118 rc,
1119 kcenon::pacs::compat::format("Failed to update modalities: {}", sqlite3_errmsg(db_)),
1120 "storage");
1121 }
1122
1123 return ok();
1124}
1125
1126} // namespace kcenon::pacs::storage
1127
1128#endif // PACS_WITH_DATABASE_SYSTEM
Repository for study metadata persistence (legacy SQLite interface)
auto find_study_by_pk(int64_t pk) const -> std::optional< study_record >
auto study_count_for_patient(int64_t patient_pk) const -> Result< size_t >
auto delete_study(std::string_view study_uid) -> VoidResult
auto find_study(std::string_view study_uid) const -> std::optional< study_record >
auto upsert_study(int64_t patient_pk, std::string_view study_uid, std::string_view study_id="", std::string_view study_date="", std::string_view study_time="", std::string_view accession_number="", std::string_view referring_physician="", std::string_view study_description="") -> Result< int64_t >
auto parse_study_row(void *stmt) const -> study_record
auto update_modalities_in_study(int64_t study_pk) -> VoidResult
auto search_studies(const study_query &query) const -> Result< std::vector< study_record > >
auto study_count() const -> Result< size_t >
Compatibility header providing kcenon::pacs::compat::format as an alias for std::format.
constexpr int database_query_error
Definition result.h:122
@ move
C-MOVE move request/response.
const atna_coded_value query
Query (110112)
Result<T> type aliases and helpers for PACS system.
Study record from the database.
Repository for study metadata persistence using base_repository pattern.