PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
series_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 <sstream>
19
20#ifdef PACS_WITH_DATABASE_SYSTEM
21
22#include <database/query_builder.h>
24
25namespace kcenon::pacs::storage {
26
27using kcenon::common::make_error;
28using kcenon::common::ok;
29
30series_repository::series_repository(std::shared_ptr<pacs_database_adapter> db)
31 : base_repository(std::move(db), "series", "series_pk") {}
32
33auto series_repository::parse_timestamp(const std::string& str) const
34 -> std::chrono::system_clock::time_point {
35 if (str.empty()) {
36 return {};
37 }
38
39 std::tm tm{};
40 if (std::sscanf(str.c_str(), "%d-%d-%d %d:%d:%d", &tm.tm_year, &tm.tm_mon,
41 &tm.tm_mday, &tm.tm_hour, &tm.tm_min, &tm.tm_sec) != 6) {
42 return {};
43 }
44
45 tm.tm_year -= 1900;
46 tm.tm_mon -= 1;
47
48#ifdef _WIN32
49 auto time = _mkgmtime(&tm);
50#else
51 auto time = timegm(&tm);
52#endif
53
54 return std::chrono::system_clock::from_time_t(time);
55}
56
57auto series_repository::format_timestamp(
58 std::chrono::system_clock::time_point tp) const -> std::string {
59 if (tp == std::chrono::system_clock::time_point{}) {
60 return "";
61 }
62
63 auto time = std::chrono::system_clock::to_time_t(tp);
64 std::tm tm{};
65#ifdef _WIN32
66 gmtime_s(&tm, &time);
67#else
68 gmtime_r(&time, &tm);
69#endif
70
71 char buf[32];
72 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
73 return buf;
74}
75
76auto series_repository::to_like_pattern(std::string_view pattern)
77 -> std::string {
78 std::string result;
79 result.reserve(pattern.size());
80
81 for (char c : pattern) {
82 if (c == '*') {
83 result += '%';
84 } else if (c == '?') {
85 result += '_';
86 } else if (c == '%' || c == '_') {
87 result += '\\';
88 result += c;
89 } else {
90 result += c;
91 }
92 }
93
94 return result;
95}
96
97auto series_repository::upsert_series(int64_t study_pk,
98 std::string_view series_uid,
99 std::string_view modality,
100 std::optional<int> series_number,
101 std::string_view series_description,
102 std::string_view body_part_examined,
103 std::string_view station_name)
104 -> Result<int64_t> {
105 series_record record;
106 record.study_pk = study_pk;
107 record.series_uid = std::string(series_uid);
108 record.modality = std::string(modality);
109 record.series_number = series_number;
110 record.series_description = std::string(series_description);
111 record.body_part_examined = std::string(body_part_examined);
112 record.station_name = std::string(station_name);
113 return upsert_series(record);
114}
115
116auto series_repository::upsert_series(const series_record& record)
117 -> Result<int64_t> {
118 if (record.series_uid.empty()) {
119 return make_error<int64_t>(-1, "Series Instance UID is required",
120 "storage");
121 }
122
123 if (record.series_uid.length() > 64) {
124 return make_error<int64_t>(
125 -1, "Series Instance UID exceeds maximum length of 64 characters",
126 "storage");
127 }
128
129 if (record.study_pk <= 0) {
130 return make_error<int64_t>(-1, "Valid study_pk is required", "storage");
131 }
132
133 if (!db() || !db()->is_connected()) {
134 return make_error<int64_t>(-1, "Database not connected", "storage");
135 }
136
137 auto builder = query_builder();
138 auto check_sql = builder.select(std::vector<std::string>{"series_pk"})
139 .from("series")
140 .where("series_uid", "=", record.series_uid)
141 .build();
142
143 auto check_result = db()->select(check_sql);
144 if (check_result.is_err()) {
145 return make_error<int64_t>(
146 -1,
147 kcenon::pacs::compat::format("Failed to check series existence: {}",
148 check_result.error().message),
149 "storage");
150 }
151
152 if (!check_result.value().empty()) {
153 auto existing = find_series(record.series_uid);
154 if (!existing.has_value()) {
155 return make_error<int64_t>(
156 -1, "Series exists but could not retrieve record", "storage");
157 }
158
159 std::map<std::string, database::core::database_value> update_data{
160 {"study_pk", std::to_string(record.study_pk)},
161 {"modality", record.modality},
162 {"series_description", record.series_description},
163 {"body_part_examined", record.body_part_examined},
164 {"station_name", record.station_name},
165 {"updated_at", std::string("datetime('now')")}};
166 if (record.series_number.has_value()) {
167 update_data["series_number"] =
168 std::to_string(*record.series_number);
169 }
170
171 database::query_builder update_builder(database::database_types::sqlite);
172 auto update_sql = update_builder.update("series")
173 .set(update_data)
174 .where("series_uid", "=", record.series_uid)
175 .build();
176 auto update_result = db()->update(update_sql);
177 if (update_result.is_err()) {
178 return make_error<int64_t>(
179 -1,
180 kcenon::pacs::compat::format("Failed to update series: {}",
181 update_result.error().message),
182 "storage");
183 }
184
185 return existing->pk;
186 }
187
188 std::map<std::string, database::core::database_value> insert_data{
189 {"study_pk", std::to_string(record.study_pk)},
190 {"series_uid", record.series_uid},
191 {"modality", record.modality},
192 {"series_description", record.series_description},
193 {"body_part_examined", record.body_part_examined},
194 {"station_name", record.station_name}};
195 if (record.series_number.has_value()) {
196 insert_data["series_number"] = std::to_string(*record.series_number);
197 }
198
199 database::query_builder insert_builder(database::database_types::sqlite);
200 insert_builder.insert_into("series").values(insert_data);
201
202 auto insert_result = db()->insert(insert_builder.build());
203 if (insert_result.is_err()) {
204 return make_error<int64_t>(
205 -1,
206 kcenon::pacs::compat::format("Failed to insert series: {}",
207 insert_result.error().message),
208 "storage");
209 }
210
211 auto inserted = find_series(record.series_uid);
212 if (!inserted.has_value()) {
213 return make_error<int64_t>(
214 -1, "Series inserted but could not retrieve record", "storage");
215 }
216
217 return inserted->pk;
218}
219
220auto series_repository::find_series(std::string_view series_uid)
221 -> std::optional<series_record> {
222 if (!db() || !db()->is_connected()) {
223 return std::nullopt;
224 }
225
226 auto builder = query_builder();
227 auto select_sql =
228 builder.select(select_columns())
229 .from(table_name())
230 .where("series_uid", "=", std::string(series_uid))
231 .build();
232
233 auto result = db()->select(select_sql);
234 if (result.is_err() || result.value().empty()) {
235 return std::nullopt;
236 }
237
238 return map_row_to_entity(result.value()[0]);
239}
240
241auto series_repository::find_series_by_pk(int64_t pk)
242 -> std::optional<series_record> {
243 auto result = find_by_id(pk);
244 if (result.is_err()) {
245 return std::nullopt;
246 }
247 return result.value();
248}
249
250auto series_repository::list_series(std::string_view study_uid)
251 -> Result<std::vector<series_record>> {
252 if (!db() || !db()->is_connected()) {
253 return make_error<std::vector<series_record>>(
254 -1, "Database not connected", "storage");
255 }
256
257 auto sql = kcenon::pacs::compat::format(
258 "SELECT se.series_pk, se.study_pk, se.series_uid, se.modality, "
259 "se.series_number, se.series_description, se.body_part_examined, "
260 "se.station_name, se.num_instances, se.created_at, se.updated_at "
261 "FROM series se "
262 "JOIN studies st ON se.study_pk = st.study_pk "
263 "WHERE st.study_uid = '{}' "
264 "ORDER BY se.series_number ASC, se.series_uid ASC;",
265 std::string(study_uid));
266
267 auto result = db()->select(sql);
268 if (result.is_err()) {
269 return make_error<std::vector<series_record>>(
270 -1,
271 kcenon::pacs::compat::format("Failed to list series: {}",
272 result.error().message),
273 "storage");
274 }
275
276 std::vector<series_record> records;
277 records.reserve(result.value().size());
278 for (const auto& row : result.value()) {
279 records.push_back(map_row_to_entity(row));
280 }
281
282 return ok(std::move(records));
283}
284
285auto series_repository::search_series(const series_query& query)
286 -> Result<std::vector<series_record>> {
287 if (!db() || !db()->is_connected()) {
288 return make_error<std::vector<series_record>>(
289 -1, "Database not connected", "storage");
290 }
291
292 std::vector<std::string> where_clauses;
293
294 if (query.study_uid.has_value()) {
295 where_clauses.push_back(
296 kcenon::pacs::compat::format("st.study_uid = '{}'", *query.study_uid));
297 }
298 if (query.series_uid.has_value()) {
299 where_clauses.push_back(
300 kcenon::pacs::compat::format("se.series_uid = '{}'", *query.series_uid));
301 }
302 if (query.modality.has_value()) {
303 where_clauses.push_back(
304 kcenon::pacs::compat::format("se.modality = '{}'", *query.modality));
305 }
306 if (query.series_description.has_value()) {
307 where_clauses.push_back(kcenon::pacs::compat::format(
308 "se.series_description LIKE '{}'",
309 to_like_pattern(*query.series_description)));
310 }
311 if (query.body_part_examined.has_value()) {
312 where_clauses.push_back(kcenon::pacs::compat::format(
313 "se.body_part_examined = '{}'", *query.body_part_examined));
314 }
315 if (query.series_number.has_value()) {
316 where_clauses.push_back(kcenon::pacs::compat::format(
317 "se.series_number = {}", *query.series_number));
318 }
319
320 std::string sql =
321 "SELECT se.series_pk, se.study_pk, se.series_uid, se.modality, "
322 "se.series_number, se.series_description, se.body_part_examined, "
323 "se.station_name, se.num_instances, se.created_at, se.updated_at "
324 "FROM series se "
325 "JOIN studies st ON se.study_pk = st.study_pk";
326
327 if (!where_clauses.empty()) {
328 sql += " WHERE " + where_clauses.front();
329 for (size_t i = 1; i < where_clauses.size(); ++i) {
330 sql += " AND " + where_clauses[i];
331 }
332 }
333
334 sql += " ORDER BY se.series_number ASC, se.series_uid ASC";
335
336 if (query.limit > 0) {
337 sql += kcenon::pacs::compat::format(" LIMIT {}", query.limit);
338 }
339 if (query.offset > 0) {
340 sql += kcenon::pacs::compat::format(" OFFSET {}", query.offset);
341 }
342
343 auto result = db()->select(sql);
344 if (result.is_err()) {
345 return make_error<std::vector<series_record>>(
346 -1,
347 kcenon::pacs::compat::format("Failed to search series: {}",
348 result.error().message),
349 "storage");
350 }
351
352 std::vector<series_record> records;
353 records.reserve(result.value().size());
354 for (const auto& row : result.value()) {
355 records.push_back(map_row_to_entity(row));
356 }
357
358 return ok(std::move(records));
359}
360
361auto series_repository::delete_series(std::string_view series_uid)
362 -> VoidResult {
363 if (!db() || !db()->is_connected()) {
364 return make_error<std::monostate>(-1, "Database not connected",
365 "storage");
366 }
367
368 auto builder = query_builder();
369 auto delete_sql =
370 builder.delete_from(table_name())
371 .where("series_uid", "=", std::string(series_uid))
372 .build();
373
374 auto result = db()->remove(delete_sql);
375 if (result.is_err()) {
376 return make_error<std::monostate>(
377 -1,
378 kcenon::pacs::compat::format("Failed to delete series: {}",
379 result.error().message),
380 "storage");
381 }
382
383 return ok();
384}
385
386auto series_repository::series_count() -> Result<size_t> {
387 return count();
388}
389
390auto series_repository::series_count(std::string_view study_uid)
391 -> Result<size_t> {
392 if (!db() || !db()->is_connected()) {
393 return make_error<size_t>(-1, "Database not connected", "storage");
394 }
395
396 auto sql = kcenon::pacs::compat::format(
397 "SELECT COUNT(*) AS cnt "
398 "FROM series se "
399 "JOIN studies st ON se.study_pk = st.study_pk "
400 "WHERE st.study_uid = '{}';",
401 std::string(study_uid));
402
403 auto result = db()->select(sql);
404 if (result.is_err()) {
405 return make_error<size_t>(
406 -1,
407 kcenon::pacs::compat::format("Failed to count series: {}",
408 result.error().message),
409 "storage");
410 }
411
412 if (result.value().empty()) {
413 return ok(static_cast<size_t>(0));
414 }
415
416 const auto& row = result.value()[0];
417 auto it = row.find("cnt");
418 if (it == row.end() && !row.empty()) {
419 it = row.begin();
420 }
421 if (it == row.end() || it->second.empty()) {
422 return ok(static_cast<size_t>(0));
423 }
424
425 try {
426 return ok(static_cast<size_t>(std::stoull(it->second)));
427 } catch (...) {
428 return ok(static_cast<size_t>(0));
429 }
430}
431
432auto series_repository::map_row_to_entity(const database_row& row) const
433 -> series_record {
434 series_record record;
435
436 auto get_str = [&row](const std::string& key) -> std::string {
437 auto it = row.find(key);
438 return (it != row.end()) ? it->second : std::string{};
439 };
440
441 auto get_int64 = [&row](const std::string& key) -> int64_t {
442 auto it = row.find(key);
443 if (it != row.end() && !it->second.empty()) {
444 try {
445 return std::stoll(it->second);
446 } catch (...) {
447 return 0;
448 }
449 }
450 return 0;
451 };
452
453 auto get_optional_int = [&row](const std::string& key) -> std::optional<int> {
454 auto it = row.find(key);
455 if (it != row.end() && !it->second.empty()) {
456 try {
457 return std::stoi(it->second);
458 } catch (...) {
459 return std::nullopt;
460 }
461 }
462 return std::nullopt;
463 };
464
465 record.pk = get_int64("series_pk");
466 record.study_pk = get_int64("study_pk");
467 record.series_uid = get_str("series_uid");
468 record.modality = get_str("modality");
469 record.series_number = get_optional_int("series_number");
470 record.series_description = get_str("series_description");
471 record.body_part_examined = get_str("body_part_examined");
472 record.station_name = get_str("station_name");
473 record.num_instances = get_optional_int("num_instances").value_or(0);
474
475 auto created_at_str = get_str("created_at");
476 if (!created_at_str.empty()) {
477 record.created_at = parse_timestamp(created_at_str);
478 }
479
480 auto updated_at_str = get_str("updated_at");
481 if (!updated_at_str.empty()) {
482 record.updated_at = parse_timestamp(updated_at_str);
483 }
484
485 return record;
486}
487
488auto series_repository::entity_to_row(const series_record& entity) const
489 -> std::map<std::string, database_value> {
490 std::map<std::string, database_value> row;
491
492 row["study_pk"] = std::to_string(entity.study_pk);
493 row["series_uid"] = entity.series_uid;
494 row["modality"] = entity.modality;
495 if (entity.series_number.has_value()) {
496 row["series_number"] = std::to_string(*entity.series_number);
497 }
498 row["series_description"] = entity.series_description;
499 row["body_part_examined"] = entity.body_part_examined;
500 row["station_name"] = entity.station_name;
501 row["num_instances"] = std::to_string(entity.num_instances);
502
503 auto now = std::chrono::system_clock::now();
504 if (entity.created_at != std::chrono::system_clock::time_point{}) {
505 row["created_at"] = format_timestamp(entity.created_at);
506 } else {
507 row["created_at"] = format_timestamp(now);
508 }
509
510 if (entity.updated_at != std::chrono::system_clock::time_point{}) {
511 row["updated_at"] = format_timestamp(entity.updated_at);
512 } else {
513 row["updated_at"] = format_timestamp(now);
514 }
515
516 return row;
517}
518
519auto series_repository::get_pk(const series_record& entity) const -> int64_t {
520 return entity.pk;
521}
522
523auto series_repository::has_pk(const series_record& entity) const -> bool {
524 return entity.pk > 0;
525}
526
527auto series_repository::select_columns() const -> std::vector<std::string> {
528 return {"series_pk", "study_pk", "series_uid",
529 "modality", "series_number", "series_description",
530 "body_part_examined", "station_name", "num_instances",
531 "created_at", "updated_at"};
532}
533
534} // namespace kcenon::pacs::storage
535
536#else // !PACS_WITH_DATABASE_SYSTEM
537
540#include <sqlite3.h>
541
542namespace kcenon::pacs::storage {
543
544using kcenon::common::make_error;
545using kcenon::common::ok;
546using namespace kcenon::pacs::error_codes;
547
548namespace {
549
550auto parse_datetime(const char* str)
551 -> std::chrono::system_clock::time_point {
552 if (!str || *str == '\0') {
553 return std::chrono::system_clock::now();
554 }
555
556 std::tm tm{};
557 std::istringstream ss(str);
558 ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
559
560 if (ss.fail()) {
561 return std::chrono::system_clock::now();
562 }
563
564 return std::chrono::system_clock::from_time_t(std::mktime(&tm));
565}
566
567auto get_text(sqlite3_stmt* stmt, int col) -> std::string {
568 const auto* text =
569 reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
570 return text ? std::string(text) : std::string{};
571}
572
573} // namespace
574
576
578
580
581auto series_repository::operator=(series_repository&&) noexcept
582 -> series_repository& = default;
583
584auto series_repository::to_like_pattern(std::string_view pattern)
585 -> std::string {
586 std::string result;
587 result.reserve(pattern.size());
588
589 for (char c : pattern) {
590 if (c == '*') {
591 result += '%';
592 } else if (c == '?') {
593 result += '_';
594 } else if (c == '%' || c == '_') {
595 result += '\\';
596 result += c;
597 } else {
598 result += c;
599 }
600 }
601
602 return result;
603}
604
605auto series_repository::parse_timestamp(const std::string& str)
606 -> std::chrono::system_clock::time_point {
607 return parse_datetime(str.c_str());
608}
609
611 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
612 series_record record;
613
614 record.pk = sqlite3_column_int64(stmt, 0);
615 record.study_pk = sqlite3_column_int64(stmt, 1);
616 record.series_uid = get_text(stmt, 2);
617 record.modality = get_text(stmt, 3);
618
619 if (sqlite3_column_type(stmt, 4) != SQLITE_NULL) {
620 record.series_number = sqlite3_column_int(stmt, 4);
621 }
622
623 record.series_description = get_text(stmt, 5);
624 record.body_part_examined = get_text(stmt, 6);
625 record.station_name = get_text(stmt, 7);
626 record.num_instances = sqlite3_column_int(stmt, 8);
627 record.created_at = parse_datetime(get_text(stmt, 9).c_str());
628 record.updated_at = parse_datetime(get_text(stmt, 10).c_str());
629
630 return record;
631}
632
634 std::string_view series_uid,
635 std::string_view modality,
636 std::optional<int> series_number,
637 std::string_view series_description,
638 std::string_view body_part_examined,
639 std::string_view station_name)
640 -> Result<int64_t> {
641 series_record record;
642 record.study_pk = study_pk;
643 record.series_uid = std::string(series_uid);
644 record.modality = std::string(modality);
645 record.series_number = series_number;
646 record.series_description = std::string(series_description);
647 record.body_part_examined = std::string(body_part_examined);
648 record.station_name = std::string(station_name);
649 return upsert_series(record);
650}
651
653 -> Result<int64_t> {
654 if (record.series_uid.empty()) {
655 return make_error<int64_t>(-1, "Series Instance UID is required",
656 "storage");
657 }
658
659 if (record.series_uid.length() > 64) {
660 return make_error<int64_t>(
661 -1, "Series Instance UID exceeds maximum length of 64 characters",
662 "storage");
663 }
664
665 if (record.study_pk <= 0) {
666 return make_error<int64_t>(-1, "Valid study_pk is required", "storage");
667 }
668
669 const char* sql = R"(
670 INSERT INTO series (
671 study_pk, series_uid, modality, series_number,
672 series_description, body_part_examined, station_name,
673 updated_at
674 ) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
675 ON CONFLICT(series_uid) DO UPDATE SET
676 study_pk = excluded.study_pk,
677 modality = excluded.modality,
678 series_number = excluded.series_number,
679 series_description = excluded.series_description,
680 body_part_examined = excluded.body_part_examined,
681 station_name = excluded.station_name,
682 updated_at = datetime('now')
683 RETURNING series_pk;
684 )";
685
686 sqlite3_stmt* stmt = nullptr;
687 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
688 if (rc != SQLITE_OK) {
689 return make_error<int64_t>(
690 rc,
691 kcenon::pacs::compat::format("Failed to prepare statement: {}",
692 sqlite3_errmsg(db_)),
693 "storage");
694 }
695
696 sqlite3_bind_int64(stmt, 1, record.study_pk);
697 sqlite3_bind_text(stmt, 2, record.series_uid.c_str(), -1, SQLITE_TRANSIENT);
698 sqlite3_bind_text(stmt, 3, record.modality.c_str(), -1, SQLITE_TRANSIENT);
699
700 if (record.series_number.has_value()) {
701 sqlite3_bind_int(stmt, 4, *record.series_number);
702 } else {
703 sqlite3_bind_null(stmt, 4);
704 }
705
706 sqlite3_bind_text(stmt, 5, record.series_description.c_str(), -1,
707 SQLITE_TRANSIENT);
708 sqlite3_bind_text(stmt, 6, record.body_part_examined.c_str(), -1,
709 SQLITE_TRANSIENT);
710 sqlite3_bind_text(stmt, 7, record.station_name.c_str(), -1,
711 SQLITE_TRANSIENT);
712
713 rc = sqlite3_step(stmt);
714 if (rc != SQLITE_ROW) {
715 auto error_msg = sqlite3_errmsg(db_);
716 sqlite3_finalize(stmt);
717 return make_error<int64_t>(
718 rc, kcenon::pacs::compat::format("Failed to upsert series: {}", error_msg),
719 "storage");
720 }
721
722 auto pk = sqlite3_column_int64(stmt, 0);
723 sqlite3_finalize(stmt);
724 return pk;
725}
726
727auto series_repository::find_series(std::string_view series_uid) const
728 -> std::optional<series_record> {
729 const char* sql = R"(
730 SELECT series_pk, study_pk, series_uid, modality, series_number,
731 series_description, body_part_examined, station_name,
732 num_instances, created_at, updated_at
733 FROM series
734 WHERE series_uid = ?;
735 )";
736
737 sqlite3_stmt* stmt = nullptr;
738 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
739 if (rc != SQLITE_OK) {
740 return std::nullopt;
741 }
742
743 sqlite3_bind_text(stmt, 1, series_uid.data(),
744 static_cast<int>(series_uid.size()), SQLITE_TRANSIENT);
745
746 rc = sqlite3_step(stmt);
747 if (rc != SQLITE_ROW) {
748 sqlite3_finalize(stmt);
749 return std::nullopt;
750 }
751
752 auto record = parse_series_row(stmt);
753 sqlite3_finalize(stmt);
754 return record;
755}
756
758 -> std::optional<series_record> {
759 const char* sql = R"(
760 SELECT series_pk, study_pk, series_uid, modality, series_number,
761 series_description, body_part_examined, station_name,
762 num_instances, created_at, updated_at
763 FROM series
764 WHERE series_pk = ?;
765 )";
766
767 sqlite3_stmt* stmt = nullptr;
768 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
769 if (rc != SQLITE_OK) {
770 return std::nullopt;
771 }
772
773 sqlite3_bind_int64(stmt, 1, pk);
774
775 rc = sqlite3_step(stmt);
776 if (rc != SQLITE_ROW) {
777 sqlite3_finalize(stmt);
778 return std::nullopt;
779 }
780
781 auto record = parse_series_row(stmt);
782 sqlite3_finalize(stmt);
783 return record;
784}
785
786auto series_repository::list_series(std::string_view study_uid) const
788 std::vector<series_record> results;
789
790 const char* sql = R"(
791 SELECT se.series_pk, se.study_pk, se.series_uid, se.modality,
792 se.series_number, se.series_description, se.body_part_examined,
793 se.station_name, se.num_instances, se.created_at, se.updated_at
794 FROM series se
795 JOIN studies st ON se.study_pk = st.study_pk
796 WHERE st.study_uid = ?
797 ORDER BY se.series_number ASC, se.series_uid ASC;
798 )";
799
800 sqlite3_stmt* stmt = nullptr;
801 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
802 if (rc != SQLITE_OK) {
803 return make_error<std::vector<series_record>>(
805 kcenon::pacs::compat::format("Failed to prepare query: {}",
806 sqlite3_errmsg(db_)),
807 "storage");
808 }
809
810 sqlite3_bind_text(stmt, 1, study_uid.data(),
811 static_cast<int>(study_uid.size()), SQLITE_TRANSIENT);
812
813 while (sqlite3_step(stmt) == SQLITE_ROW) {
814 results.push_back(parse_series_row(stmt));
815 }
816
817 sqlite3_finalize(stmt);
818 return ok(std::move(results));
819}
820
823 std::vector<series_record> results;
824
825 std::string sql = R"(
826 SELECT se.series_pk, se.study_pk, se.series_uid, se.modality,
827 se.series_number, se.series_description, se.body_part_examined,
828 se.station_name, se.num_instances, se.created_at, se.updated_at
829 FROM series se
830 JOIN studies st ON se.study_pk = st.study_pk
831 WHERE 1=1
832 )";
833
834 std::vector<std::string> params;
835
836 if (query.study_uid.has_value()) {
837 sql += " AND st.study_uid = ?";
838 params.push_back(*query.study_uid);
839 }
840 if (query.series_uid.has_value()) {
841 sql += " AND se.series_uid = ?";
842 params.push_back(*query.series_uid);
843 }
844 if (query.modality.has_value()) {
845 sql += " AND se.modality = ?";
846 params.push_back(*query.modality);
847 }
848 if (query.series_description.has_value()) {
849 sql += " AND se.series_description LIKE ?";
850 params.push_back(to_like_pattern(*query.series_description));
851 }
852 if (query.body_part_examined.has_value()) {
853 sql += " AND se.body_part_examined = ?";
854 params.push_back(*query.body_part_examined);
855 }
856
857 sql += " ORDER BY se.series_number ASC, se.series_uid ASC";
858
859 if (query.limit > 0) {
860 sql += kcenon::pacs::compat::format(" LIMIT {}", query.limit);
861 }
862 if (query.offset > 0) {
863 sql += kcenon::pacs::compat::format(" OFFSET {}", query.offset);
864 }
865
866 sqlite3_stmt* stmt = nullptr;
867 auto rc = sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
868 if (rc != SQLITE_OK) {
869 return make_error<std::vector<series_record>>(
871 kcenon::pacs::compat::format("Failed to prepare query: {}",
872 sqlite3_errmsg(db_)),
873 "storage");
874 }
875
876 int bind_index = 1;
877 for (const auto& param : params) {
878 sqlite3_bind_text(stmt, bind_index++, param.c_str(), -1,
879 SQLITE_TRANSIENT);
880 }
881
882 while (sqlite3_step(stmt) == SQLITE_ROW) {
883 auto record = parse_series_row(stmt);
884 if (query.series_number.has_value()) {
885 if (!record.series_number.has_value() ||
886 *record.series_number != *query.series_number) {
887 continue;
888 }
889 }
890 results.push_back(std::move(record));
891 }
892
893 sqlite3_finalize(stmt);
894 return ok(std::move(results));
895}
896
897auto series_repository::delete_series(std::string_view series_uid)
898 -> VoidResult {
899 const char* sql = "DELETE FROM series WHERE series_uid = ?;";
900
901 sqlite3_stmt* stmt = nullptr;
902 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
903 if (rc != SQLITE_OK) {
904 return make_error<std::monostate>(
905 rc,
906 kcenon::pacs::compat::format("Failed to prepare delete: {}",
907 sqlite3_errmsg(db_)),
908 "storage");
909 }
910
911 sqlite3_bind_text(stmt, 1, series_uid.data(),
912 static_cast<int>(series_uid.size()), SQLITE_TRANSIENT);
913
914 rc = sqlite3_step(stmt);
915 sqlite3_finalize(stmt);
916
917 if (rc != SQLITE_DONE) {
918 return make_error<std::monostate>(
919 rc,
920 kcenon::pacs::compat::format("Failed to delete series: {}",
921 sqlite3_errmsg(db_)),
922 "storage");
923 }
924
925 return ok();
926}
927
929 const char* sql = "SELECT COUNT(*) FROM series;";
930
931 sqlite3_stmt* stmt = nullptr;
932 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
933 if (rc != SQLITE_OK) {
934 return make_error<size_t>(
936 kcenon::pacs::compat::format("Failed to prepare query: {}",
937 sqlite3_errmsg(db_)),
938 "storage");
939 }
940
941 size_t count = 0;
942 if (sqlite3_step(stmt) == SQLITE_ROW) {
943 count = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
944 }
945
946 sqlite3_finalize(stmt);
947 return ok(count);
948}
949
950auto series_repository::series_count(std::string_view study_uid) const
951 -> Result<size_t> {
952 const char* sql = R"(
953 SELECT COUNT(*) FROM series se
954 JOIN studies st ON se.study_pk = st.study_pk
955 WHERE st.study_uid = ?;
956 )";
957
958 sqlite3_stmt* stmt = nullptr;
959 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
960 if (rc != SQLITE_OK) {
961 return make_error<size_t>(
963 kcenon::pacs::compat::format("Failed to prepare query: {}",
964 sqlite3_errmsg(db_)),
965 "storage");
966 }
967
968 sqlite3_bind_text(stmt, 1, study_uid.data(),
969 static_cast<int>(study_uid.size()), SQLITE_TRANSIENT);
970
971 size_t count = 0;
972 if (sqlite3_step(stmt) == SQLITE_ROW) {
973 count = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
974 }
975
976 sqlite3_finalize(stmt);
977 return ok(count);
978}
979
980} // namespace kcenon::pacs::storage
981
982#endif // PACS_WITH_DATABASE_SYSTEM
Repository for series metadata persistence (legacy SQLite interface)
auto series_count() const -> Result< size_t >
auto find_series(std::string_view series_uid) const -> std::optional< series_record >
auto list_series(std::string_view study_uid) const -> Result< std::vector< series_record > >
auto delete_series(std::string_view series_uid) -> VoidResult
auto find_series_by_pk(int64_t pk) const -> std::optional< series_record >
auto parse_series_row(void *stmt) const -> series_record
auto search_series(const series_query &query) const -> Result< std::vector< series_record > >
auto upsert_series(int64_t study_pk, std::string_view series_uid, std::string_view modality="", std::optional< int > series_number=std::nullopt, std::string_view series_description="", std::string_view body_part_examined="", std::string_view station_name="") -> Result< int64_t >
static auto parse_timestamp(const std::string &str) -> std::chrono::system_clock::time_point
Compatibility header providing kcenon::pacs::compat::format as an alias for std::format.
constexpr dicom_tag series_number
Series Number.
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.
Repository for series metadata persistence using base_repository pattern.
Series record from the database.