PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
instance_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
31 std::shared_ptr<pacs_database_adapter> db)
32 : base_repository(std::move(db), "instances", "instance_pk") {}
33
34auto instance_repository::parse_timestamp(const std::string& str) const
35 -> std::chrono::system_clock::time_point {
36 if (str.empty()) {
37 return {};
38 }
39
40 std::tm tm{};
41 if (std::sscanf(str.c_str(), "%d-%d-%d %d:%d:%d", &tm.tm_year, &tm.tm_mon,
42 &tm.tm_mday, &tm.tm_hour, &tm.tm_min, &tm.tm_sec) != 6) {
43 return {};
44 }
45
46 tm.tm_year -= 1900;
47 tm.tm_mon -= 1;
48
49#ifdef _WIN32
50 auto time = _mkgmtime(&tm);
51#else
52 auto time = timegm(&tm);
53#endif
54
55 return std::chrono::system_clock::from_time_t(time);
56}
57
58auto instance_repository::format_timestamp(
59 std::chrono::system_clock::time_point tp) const -> std::string {
60 if (tp == std::chrono::system_clock::time_point{}) {
61 return "";
62 }
63
64 auto time = std::chrono::system_clock::to_time_t(tp);
65 std::tm tm{};
66#ifdef _WIN32
67 gmtime_s(&tm, &time);
68#else
69 gmtime_r(&time, &tm);
70#endif
71
72 char buf[32];
73 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
74 return buf;
75}
76
77auto instance_repository::upsert_instance(int64_t series_pk,
78 std::string_view sop_uid,
79 std::string_view sop_class_uid,
80 std::string_view file_path,
81 int64_t file_size,
82 std::string_view transfer_syntax,
83 std::optional<int> instance_number)
84 -> Result<int64_t> {
85 instance_record record;
86 record.series_pk = series_pk;
87 record.sop_uid = std::string(sop_uid);
88 record.sop_class_uid = std::string(sop_class_uid);
89 record.file_path = std::string(file_path);
90 record.file_size = file_size;
91 record.transfer_syntax = std::string(transfer_syntax);
92 record.instance_number = instance_number;
93 return upsert_instance(record);
94}
95
96auto instance_repository::upsert_instance(const instance_record& record)
97 -> Result<int64_t> {
98 if (record.sop_uid.empty()) {
99 return make_error<int64_t>(-1, "SOP Instance UID is required",
100 "storage");
101 }
102
103 if (record.sop_uid.length() > 64) {
104 return make_error<int64_t>(
105 -1, "SOP Instance UID exceeds maximum length of 64 characters",
106 "storage");
107 }
108
109 if (record.series_pk <= 0) {
110 return make_error<int64_t>(-1, "Valid series_pk is required",
111 "storage");
112 }
113
114 if (record.file_path.empty()) {
115 return make_error<int64_t>(-1, "File path is required", "storage");
116 }
117
118 if (record.file_size < 0) {
119 return make_error<int64_t>(-1, "File size must be non-negative",
120 "storage");
121 }
122
123 if (!db() || !db()->is_connected()) {
124 return make_error<int64_t>(-1, "Database not connected", "storage");
125 }
126
127 auto builder = query_builder();
128 auto check_sql = builder.select(std::vector<std::string>{"instance_pk"})
129 .from("instances")
130 .where("sop_uid", "=", record.sop_uid)
131 .build();
132
133 auto check_result = db()->select(check_sql);
134 if (check_result.is_err()) {
135 return make_error<int64_t>(
136 -1,
137 kcenon::pacs::compat::format("Failed to check instance existence: {}",
138 check_result.error().message),
139 "storage");
140 }
141
142 if (!check_result.value().empty()) {
143 auto existing = find_instance(record.sop_uid);
144 if (!existing.has_value()) {
145 return make_error<int64_t>(
146 -1, "Instance exists but could not retrieve record", "storage");
147 }
148
149 std::map<std::string, database::core::database_value> update_data{
150 {"series_pk", std::to_string(record.series_pk)},
151 {"sop_class_uid", record.sop_class_uid},
152 {"transfer_syntax", record.transfer_syntax},
153 {"content_date", record.content_date},
154 {"content_time", record.content_time},
155 {"file_path", record.file_path},
156 {"file_size", std::to_string(record.file_size)},
157 {"file_hash", record.file_hash}};
158 if (record.instance_number.has_value()) {
159 update_data["instance_number"] =
160 std::to_string(*record.instance_number);
161 }
162 if (record.rows.has_value()) {
163 update_data["rows"] = std::to_string(*record.rows);
164 }
165 if (record.columns.has_value()) {
166 update_data["columns"] = std::to_string(*record.columns);
167 }
168 if (record.bits_allocated.has_value()) {
169 update_data["bits_allocated"] =
170 std::to_string(*record.bits_allocated);
171 }
172 if (record.number_of_frames.has_value()) {
173 update_data["number_of_frames"] =
174 std::to_string(*record.number_of_frames);
175 }
176
177 database::query_builder update_builder(database::database_types::sqlite);
178 auto update_sql = update_builder.update("instances")
179 .set(update_data)
180 .where("sop_uid", "=", record.sop_uid)
181 .build();
182 auto update_result = db()->update(update_sql);
183 if (update_result.is_err()) {
184 return make_error<int64_t>(
185 -1,
186 kcenon::pacs::compat::format("Failed to update instance: {}",
187 update_result.error().message),
188 "storage");
189 }
190
191 return existing->pk;
192 }
193
194 std::map<std::string, database::core::database_value> insert_data{
195 {"series_pk", std::to_string(record.series_pk)},
196 {"sop_uid", record.sop_uid},
197 {"sop_class_uid", record.sop_class_uid},
198 {"transfer_syntax", record.transfer_syntax},
199 {"content_date", record.content_date},
200 {"content_time", record.content_time},
201 {"file_path", record.file_path},
202 {"file_size", std::to_string(record.file_size)},
203 {"file_hash", record.file_hash}};
204 if (record.instance_number.has_value()) {
205 insert_data["instance_number"] =
206 std::to_string(*record.instance_number);
207 }
208 if (record.rows.has_value()) {
209 insert_data["rows"] = std::to_string(*record.rows);
210 }
211 if (record.columns.has_value()) {
212 insert_data["columns"] = std::to_string(*record.columns);
213 }
214 if (record.bits_allocated.has_value()) {
215 insert_data["bits_allocated"] =
216 std::to_string(*record.bits_allocated);
217 }
218 if (record.number_of_frames.has_value()) {
219 insert_data["number_of_frames"] =
220 std::to_string(*record.number_of_frames);
221 }
222
223 database::query_builder insert_builder(database::database_types::sqlite);
224 insert_builder.insert_into("instances").values(insert_data);
225
226 auto insert_result = db()->insert(insert_builder.build());
227 if (insert_result.is_err()) {
228 return make_error<int64_t>(
229 -1,
230 kcenon::pacs::compat::format("Failed to insert instance: {}",
231 insert_result.error().message),
232 "storage");
233 }
234
235 auto inserted = find_instance(record.sop_uid);
236 if (!inserted.has_value()) {
237 return make_error<int64_t>(
238 -1, "Instance inserted but could not retrieve record", "storage");
239 }
240
241 return inserted->pk;
242}
243
244auto instance_repository::find_instance(std::string_view sop_uid)
245 -> std::optional<instance_record> {
246 if (!db() || !db()->is_connected()) {
247 return std::nullopt;
248 }
249
250 auto builder = query_builder();
251 auto select_sql =
252 builder.select(select_columns())
253 .from(table_name())
254 .where("sop_uid", "=", std::string(sop_uid))
255 .build();
256
257 auto result = db()->select(select_sql);
258 if (result.is_err() || result.value().empty()) {
259 return std::nullopt;
260 }
261
262 return map_row_to_entity(result.value()[0]);
263}
264
265auto instance_repository::find_instance_by_pk(int64_t pk)
266 -> std::optional<instance_record> {
267 auto result = find_by_id(pk);
268 if (result.is_err()) {
269 return std::nullopt;
270 }
271 return result.value();
272}
273
274auto instance_repository::list_instances(std::string_view series_uid)
275 -> Result<std::vector<instance_record>> {
276 if (!db() || !db()->is_connected()) {
277 return make_error<std::vector<instance_record>>(
278 -1, "Database not connected", "storage");
279 }
280
281 auto sql = kcenon::pacs::compat::format(
282 "SELECT i.instance_pk, i.series_pk, i.sop_uid, i.sop_class_uid, "
283 "i.instance_number, i.transfer_syntax, i.content_date, i.content_time, "
284 "i.rows, i.columns, i.bits_allocated, i.number_of_frames, "
285 "i.file_path, i.file_size, i.file_hash, i.created_at "
286 "FROM instances i "
287 "JOIN series s ON i.series_pk = s.series_pk "
288 "WHERE s.series_uid = '{}' "
289 "ORDER BY i.instance_number ASC, i.sop_uid ASC;",
290 std::string(series_uid));
291
292 auto result = db()->select(sql);
293 if (result.is_err()) {
294 return make_error<std::vector<instance_record>>(
295 -1,
296 kcenon::pacs::compat::format("Failed to list instances: {}",
297 result.error().message),
298 "storage");
299 }
300
301 std::vector<instance_record> records;
302 records.reserve(result.value().size());
303 for (const auto& row : result.value()) {
304 records.push_back(map_row_to_entity(row));
305 }
306
307 return ok(std::move(records));
308}
309
310auto instance_repository::search_instances(const instance_query& query)
311 -> Result<std::vector<instance_record>> {
312 if (!db() || !db()->is_connected()) {
313 return make_error<std::vector<instance_record>>(
314 -1, "Database not connected", "storage");
315 }
316
317 std::vector<std::string> where_clauses;
318
319 if (query.series_uid.has_value()) {
320 where_clauses.push_back(
321 kcenon::pacs::compat::format("s.series_uid = '{}'", *query.series_uid));
322 }
323 if (query.sop_uid.has_value()) {
324 where_clauses.push_back(
325 kcenon::pacs::compat::format("i.sop_uid = '{}'", *query.sop_uid));
326 }
327 if (query.sop_class_uid.has_value()) {
328 where_clauses.push_back(
329 kcenon::pacs::compat::format("i.sop_class_uid = '{}'",
330 *query.sop_class_uid));
331 }
332 if (query.content_date.has_value()) {
333 where_clauses.push_back(
334 kcenon::pacs::compat::format("i.content_date = '{}'", *query.content_date));
335 }
336 if (query.content_date_from.has_value()) {
337 where_clauses.push_back(kcenon::pacs::compat::format(
338 "i.content_date >= '{}'", *query.content_date_from));
339 }
340 if (query.content_date_to.has_value()) {
341 where_clauses.push_back(
342 kcenon::pacs::compat::format("i.content_date <= '{}'",
343 *query.content_date_to));
344 }
345 if (query.instance_number.has_value()) {
346 where_clauses.push_back(kcenon::pacs::compat::format(
347 "i.instance_number = {}", *query.instance_number));
348 }
349
350 std::string sql =
351 "SELECT i.instance_pk, i.series_pk, i.sop_uid, i.sop_class_uid, "
352 "i.instance_number, i.transfer_syntax, i.content_date, i.content_time, "
353 "i.rows, i.columns, i.bits_allocated, i.number_of_frames, "
354 "i.file_path, i.file_size, i.file_hash, i.created_at "
355 "FROM instances i "
356 "JOIN series s ON i.series_pk = s.series_pk";
357
358 if (!where_clauses.empty()) {
359 sql += " WHERE " + where_clauses.front();
360 for (size_t i = 1; i < where_clauses.size(); ++i) {
361 sql += " AND " + where_clauses[i];
362 }
363 }
364
365 sql += " ORDER BY i.instance_number ASC, i.sop_uid ASC";
366
367 if (query.limit > 0) {
368 sql += kcenon::pacs::compat::format(" LIMIT {}", query.limit);
369 }
370 if (query.offset > 0) {
371 sql += kcenon::pacs::compat::format(" OFFSET {}", query.offset);
372 }
373
374 auto result = db()->select(sql);
375 if (result.is_err()) {
376 return make_error<std::vector<instance_record>>(
377 -1,
378 kcenon::pacs::compat::format("Failed to search instances: {}",
379 result.error().message),
380 "storage");
381 }
382
383 std::vector<instance_record> records;
384 records.reserve(result.value().size());
385 for (const auto& row : result.value()) {
386 records.push_back(map_row_to_entity(row));
387 }
388
389 return ok(std::move(records));
390}
391
392auto instance_repository::delete_instance(std::string_view sop_uid)
393 -> VoidResult {
394 if (!db() || !db()->is_connected()) {
395 return make_error<std::monostate>(-1, "Database not connected",
396 "storage");
397 }
398
399 auto builder = query_builder();
400 auto delete_sql =
401 builder.delete_from(table_name())
402 .where("sop_uid", "=", std::string(sop_uid))
403 .build();
404
405 auto result = db()->remove(delete_sql);
406 if (result.is_err()) {
407 return make_error<std::monostate>(
408 -1,
409 kcenon::pacs::compat::format("Failed to delete instance: {}",
410 result.error().message),
411 "storage");
412 }
413
414 return ok();
415}
416
417auto instance_repository::instance_count() -> Result<size_t> {
418 return count();
419}
420
421auto instance_repository::instance_count(std::string_view series_uid)
422 -> Result<size_t> {
423 if (!db() || !db()->is_connected()) {
424 return make_error<size_t>(-1, "Database not connected", "storage");
425 }
426
427 auto sql = kcenon::pacs::compat::format(
428 "SELECT COUNT(*) AS cnt "
429 "FROM instances i "
430 "JOIN series s ON i.series_pk = s.series_pk "
431 "WHERE s.series_uid = '{}';",
432 std::string(series_uid));
433
434 auto result = db()->select(sql);
435 if (result.is_err()) {
436 return make_error<size_t>(
437 -1,
438 kcenon::pacs::compat::format("Failed to count instances: {}",
439 result.error().message),
440 "storage");
441 }
442
443 if (result.value().empty()) {
444 return ok(static_cast<size_t>(0));
445 }
446
447 const auto& row = result.value()[0];
448 auto it = row.find("cnt");
449 if (it == row.end() && !row.empty()) {
450 it = row.begin();
451 }
452 if (it == row.end() || it->second.empty()) {
453 return ok(static_cast<size_t>(0));
454 }
455
456 try {
457 return ok(static_cast<size_t>(std::stoull(it->second)));
458 } catch (...) {
459 return ok(static_cast<size_t>(0));
460 }
461}
462
463auto instance_repository::get_file_path(std::string_view sop_instance_uid)
464 -> Result<std::optional<std::string>> {
465 if (!db() || !db()->is_connected()) {
466 return make_error<std::optional<std::string>>(
467 -1, "Database not connected", "storage");
468 }
469
470 auto builder = query_builder();
471 auto select_sql =
472 builder.select(std::vector<std::string>{"file_path"})
473 .from(table_name())
474 .where("sop_uid", "=", std::string(sop_instance_uid))
475 .build();
476
477 auto result = db()->select(select_sql);
478 if (result.is_err() || result.value().empty()) {
479 return ok(std::optional<std::string>(std::nullopt));
480 }
481
482 const auto& row = result.value()[0];
483 auto it = row.find("file_path");
484 if (it == row.end() || it->second.empty()) {
485 return ok(std::optional<std::string>(std::nullopt));
486 }
487
488 return ok(std::optional<std::string>(it->second));
489}
490
491auto instance_repository::get_study_files(std::string_view study_instance_uid)
492 -> Result<std::vector<std::string>> {
493 if (!db() || !db()->is_connected()) {
494 return make_error<std::vector<std::string>>(
495 -1, "Database not connected", "storage");
496 }
497
498 auto sql = kcenon::pacs::compat::format(
499 "SELECT i.file_path "
500 "FROM instances i "
501 "JOIN series se ON i.series_pk = se.series_pk "
502 "JOIN studies st ON se.study_pk = st.study_pk "
503 "WHERE st.study_uid = '{}' "
504 "ORDER BY se.series_number, i.instance_number;",
505 std::string(study_instance_uid));
506
507 auto result = db()->select(sql);
508 if (result.is_err()) {
509 return make_error<std::vector<std::string>>(
510 -1,
511 kcenon::pacs::compat::format("Failed to query study files: {}",
512 result.error().message),
513 "storage");
514 }
515
516 std::vector<std::string> files;
517 files.reserve(result.value().size());
518 for (const auto& row : result.value()) {
519 auto it = row.find("file_path");
520 if (it != row.end()) {
521 files.push_back(it->second);
522 }
523 }
524
525 return ok(std::move(files));
526}
527
528auto instance_repository::get_series_files(std::string_view series_instance_uid)
529 -> Result<std::vector<std::string>> {
530 if (!db() || !db()->is_connected()) {
531 return make_error<std::vector<std::string>>(
532 -1, "Database not connected", "storage");
533 }
534
535 auto sql = kcenon::pacs::compat::format(
536 "SELECT i.file_path "
537 "FROM instances i "
538 "JOIN series se ON i.series_pk = se.series_pk "
539 "WHERE se.series_uid = '{}' "
540 "ORDER BY i.instance_number;",
541 std::string(series_instance_uid));
542
543 auto result = db()->select(sql);
544 if (result.is_err()) {
545 return make_error<std::vector<std::string>>(
546 -1,
547 kcenon::pacs::compat::format("Failed to query series files: {}",
548 result.error().message),
549 "storage");
550 }
551
552 std::vector<std::string> files;
553 files.reserve(result.value().size());
554 for (const auto& row : result.value()) {
555 auto it = row.find("file_path");
556 if (it != row.end()) {
557 files.push_back(it->second);
558 }
559 }
560
561 return ok(std::move(files));
562}
563
564auto instance_repository::map_row_to_entity(const database_row& row) const
565 -> instance_record {
566 instance_record record;
567
568 auto get_str = [&row](const std::string& key) -> std::string {
569 auto it = row.find(key);
570 return (it != row.end()) ? it->second : std::string{};
571 };
572
573 auto get_int64 = [&row](const std::string& key) -> int64_t {
574 auto it = row.find(key);
575 if (it != row.end() && !it->second.empty()) {
576 try {
577 return std::stoll(it->second);
578 } catch (...) {
579 return 0;
580 }
581 }
582 return 0;
583 };
584
585 auto get_optional_int = [&row](const std::string& key) -> std::optional<int> {
586 auto it = row.find(key);
587 if (it != row.end() && !it->second.empty()) {
588 try {
589 return std::stoi(it->second);
590 } catch (...) {
591 return std::nullopt;
592 }
593 }
594 return std::nullopt;
595 };
596
597 record.pk = get_int64("instance_pk");
598 record.series_pk = get_int64("series_pk");
599 record.sop_uid = get_str("sop_uid");
600 record.sop_class_uid = get_str("sop_class_uid");
601 record.instance_number = get_optional_int("instance_number");
602 record.transfer_syntax = get_str("transfer_syntax");
603 record.content_date = get_str("content_date");
604 record.content_time = get_str("content_time");
605 record.rows = get_optional_int("rows");
606 record.columns = get_optional_int("columns");
607 record.bits_allocated = get_optional_int("bits_allocated");
608 record.number_of_frames = get_optional_int("number_of_frames");
609 record.file_path = get_str("file_path");
610 record.file_size = get_int64("file_size");
611 record.file_hash = get_str("file_hash");
612
613 auto created_at_str = get_str("created_at");
614 if (!created_at_str.empty()) {
615 record.created_at = parse_timestamp(created_at_str);
616 }
617
618 return record;
619}
620
621auto instance_repository::entity_to_row(const instance_record& entity) const
622 -> std::map<std::string, database_value> {
623 std::map<std::string, database_value> row;
624
625 row["series_pk"] = std::to_string(entity.series_pk);
626 row["sop_uid"] = entity.sop_uid;
627 row["sop_class_uid"] = entity.sop_class_uid;
628 if (entity.instance_number.has_value()) {
629 row["instance_number"] = std::to_string(*entity.instance_number);
630 }
631 row["transfer_syntax"] = entity.transfer_syntax;
632 row["content_date"] = entity.content_date;
633 row["content_time"] = entity.content_time;
634 if (entity.rows.has_value()) {
635 row["rows"] = std::to_string(*entity.rows);
636 }
637 if (entity.columns.has_value()) {
638 row["columns"] = std::to_string(*entity.columns);
639 }
640 if (entity.bits_allocated.has_value()) {
641 row["bits_allocated"] = std::to_string(*entity.bits_allocated);
642 }
643 if (entity.number_of_frames.has_value()) {
644 row["number_of_frames"] = std::to_string(*entity.number_of_frames);
645 }
646 row["file_path"] = entity.file_path;
647 row["file_size"] = std::to_string(entity.file_size);
648 row["file_hash"] = entity.file_hash;
649
650 auto now = std::chrono::system_clock::now();
651 if (entity.created_at != std::chrono::system_clock::time_point{}) {
652 row["created_at"] = format_timestamp(entity.created_at);
653 } else {
654 row["created_at"] = format_timestamp(now);
655 }
656
657 return row;
658}
659
660auto instance_repository::get_pk(const instance_record& entity) const
661 -> int64_t {
662 return entity.pk;
663}
664
665auto instance_repository::has_pk(const instance_record& entity) const -> bool {
666 return entity.pk > 0;
667}
668
669auto instance_repository::select_columns() const -> std::vector<std::string> {
670 return {"instance_pk", "series_pk", "sop_uid",
671 "sop_class_uid", "instance_number","transfer_syntax",
672 "content_date", "content_time", "rows",
673 "columns", "bits_allocated", "number_of_frames",
674 "file_path", "file_size", "file_hash",
675 "created_at"};
676}
677
678} // namespace kcenon::pacs::storage
679
680#else // !PACS_WITH_DATABASE_SYSTEM
681
684#include <sqlite3.h>
685
686namespace kcenon::pacs::storage {
687
688using kcenon::common::make_error;
689using kcenon::common::ok;
690using namespace kcenon::pacs::error_codes;
691
692namespace {
693
694auto parse_datetime(const char* str)
695 -> std::chrono::system_clock::time_point {
696 if (!str || *str == '\0') {
697 return std::chrono::system_clock::now();
698 }
699
700 std::tm tm{};
701 std::istringstream ss(str);
702 ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
703
704 if (ss.fail()) {
705 return std::chrono::system_clock::now();
706 }
707
708 return std::chrono::system_clock::from_time_t(std::mktime(&tm));
709}
710
711auto get_text(sqlite3_stmt* stmt, int col) -> std::string {
712 const auto* text =
713 reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
714 return text ? std::string(text) : std::string{};
715}
716
717} // namespace
718
720
722
724
725auto instance_repository::operator=(instance_repository&&) noexcept
726 -> instance_repository& = default;
727
728auto instance_repository::parse_timestamp(const std::string& str)
729 -> std::chrono::system_clock::time_point {
730 return parse_datetime(str.c_str());
731}
732
734 -> instance_record {
735 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
736 instance_record record;
737
738 record.pk = sqlite3_column_int64(stmt, 0);
739 record.series_pk = sqlite3_column_int64(stmt, 1);
740 record.sop_uid = get_text(stmt, 2);
741 record.sop_class_uid = get_text(stmt, 3);
742
743 if (sqlite3_column_type(stmt, 4) != SQLITE_NULL) {
744 record.instance_number = sqlite3_column_int(stmt, 4);
745 }
746
747 record.transfer_syntax = get_text(stmt, 5);
748 record.content_date = get_text(stmt, 6);
749 record.content_time = get_text(stmt, 7);
750
751 if (sqlite3_column_type(stmt, 8) != SQLITE_NULL) {
752 record.rows = sqlite3_column_int(stmt, 8);
753 }
754 if (sqlite3_column_type(stmt, 9) != SQLITE_NULL) {
755 record.columns = sqlite3_column_int(stmt, 9);
756 }
757 if (sqlite3_column_type(stmt, 10) != SQLITE_NULL) {
758 record.bits_allocated = sqlite3_column_int(stmt, 10);
759 }
760 if (sqlite3_column_type(stmt, 11) != SQLITE_NULL) {
761 record.number_of_frames = sqlite3_column_int(stmt, 11);
762 }
763
764 record.file_path = get_text(stmt, 12);
765 record.file_size = sqlite3_column_int64(stmt, 13);
766 record.file_hash = get_text(stmt, 14);
767 record.created_at = parse_datetime(get_text(stmt, 15).c_str());
768
769 return record;
770}
771
773 std::string_view sop_uid,
774 std::string_view sop_class_uid,
775 std::string_view file_path,
776 int64_t file_size,
777 std::string_view transfer_syntax,
778 std::optional<int> instance_number)
779 -> Result<int64_t> {
780 instance_record record;
781 record.series_pk = series_pk;
782 record.sop_uid = std::string(sop_uid);
783 record.sop_class_uid = std::string(sop_class_uid);
784 record.file_path = std::string(file_path);
785 record.file_size = file_size;
786 record.transfer_syntax = std::string(transfer_syntax);
787 record.instance_number = instance_number;
788 return upsert_instance(record);
789}
790
792 -> Result<int64_t> {
793 if (record.sop_uid.empty()) {
794 return make_error<int64_t>(-1, "SOP Instance UID is required",
795 "storage");
796 }
797
798 if (record.sop_uid.length() > 64) {
799 return make_error<int64_t>(
800 -1, "SOP Instance UID exceeds maximum length of 64 characters",
801 "storage");
802 }
803
804 if (record.series_pk <= 0) {
805 return make_error<int64_t>(-1, "Valid series_pk is required",
806 "storage");
807 }
808
809 if (record.file_path.empty()) {
810 return make_error<int64_t>(-1, "File path is required", "storage");
811 }
812
813 if (record.file_size < 0) {
814 return make_error<int64_t>(-1, "File size must be non-negative",
815 "storage");
816 }
817
818 const char* sql = R"(
819 INSERT INTO instances (
820 series_pk, sop_uid, sop_class_uid, instance_number,
821 transfer_syntax, content_date, content_time,
822 rows, columns, bits_allocated, number_of_frames,
823 file_path, file_size, file_hash
824 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
825 ON CONFLICT(sop_uid) DO UPDATE SET
826 series_pk = excluded.series_pk,
827 sop_class_uid = excluded.sop_class_uid,
828 instance_number = excluded.instance_number,
829 transfer_syntax = excluded.transfer_syntax,
830 content_date = excluded.content_date,
831 content_time = excluded.content_time,
832 rows = excluded.rows,
833 columns = excluded.columns,
834 bits_allocated = excluded.bits_allocated,
835 number_of_frames = excluded.number_of_frames,
836 file_path = excluded.file_path,
837 file_size = excluded.file_size,
838 file_hash = excluded.file_hash
839 RETURNING instance_pk;
840 )";
841
842 sqlite3_stmt* stmt = nullptr;
843 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
844 if (rc != SQLITE_OK) {
845 return make_error<int64_t>(
846 rc,
847 kcenon::pacs::compat::format("Failed to prepare statement: {}",
848 sqlite3_errmsg(db_)),
849 "storage");
850 }
851
852 sqlite3_bind_int64(stmt, 1, record.series_pk);
853 sqlite3_bind_text(stmt, 2, record.sop_uid.c_str(), -1, SQLITE_TRANSIENT);
854 sqlite3_bind_text(stmt, 3, record.sop_class_uid.c_str(), -1,
855 SQLITE_TRANSIENT);
856
857 if (record.instance_number.has_value()) {
858 sqlite3_bind_int(stmt, 4, *record.instance_number);
859 } else {
860 sqlite3_bind_null(stmt, 4);
861 }
862
863 sqlite3_bind_text(stmt, 5, record.transfer_syntax.c_str(), -1,
864 SQLITE_TRANSIENT);
865 sqlite3_bind_text(stmt, 6, record.content_date.c_str(), -1,
866 SQLITE_TRANSIENT);
867 sqlite3_bind_text(stmt, 7, record.content_time.c_str(), -1,
868 SQLITE_TRANSIENT);
869
870 if (record.rows.has_value()) {
871 sqlite3_bind_int(stmt, 8, *record.rows);
872 } else {
873 sqlite3_bind_null(stmt, 8);
874 }
875 if (record.columns.has_value()) {
876 sqlite3_bind_int(stmt, 9, *record.columns);
877 } else {
878 sqlite3_bind_null(stmt, 9);
879 }
880 if (record.bits_allocated.has_value()) {
881 sqlite3_bind_int(stmt, 10, *record.bits_allocated);
882 } else {
883 sqlite3_bind_null(stmt, 10);
884 }
885 if (record.number_of_frames.has_value()) {
886 sqlite3_bind_int(stmt, 11, *record.number_of_frames);
887 } else {
888 sqlite3_bind_null(stmt, 11);
889 }
890
891 sqlite3_bind_text(stmt, 12, record.file_path.c_str(), -1, SQLITE_TRANSIENT);
892 sqlite3_bind_int64(stmt, 13, record.file_size);
893 sqlite3_bind_text(stmt, 14, record.file_hash.c_str(), -1, SQLITE_TRANSIENT);
894
895 rc = sqlite3_step(stmt);
896 if (rc != SQLITE_ROW) {
897 auto error_msg = sqlite3_errmsg(db_);
898 sqlite3_finalize(stmt);
899 return make_error<int64_t>(
900 rc,
901 kcenon::pacs::compat::format("Failed to upsert instance: {}", error_msg),
902 "storage");
903 }
904
905 auto pk = sqlite3_column_int64(stmt, 0);
906 sqlite3_finalize(stmt);
907 return pk;
908}
909
910auto instance_repository::find_instance(std::string_view sop_uid) const
911 -> std::optional<instance_record> {
912 const char* sql = R"(
913 SELECT instance_pk, series_pk, sop_uid, sop_class_uid, instance_number,
914 transfer_syntax, content_date, content_time,
915 rows, columns, bits_allocated, number_of_frames,
916 file_path, file_size, file_hash, created_at
917 FROM instances
918 WHERE sop_uid = ?;
919 )";
920
921 sqlite3_stmt* stmt = nullptr;
922 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
923 if (rc != SQLITE_OK) {
924 return std::nullopt;
925 }
926
927 sqlite3_bind_text(stmt, 1, sop_uid.data(),
928 static_cast<int>(sop_uid.size()), SQLITE_TRANSIENT);
929
930 rc = sqlite3_step(stmt);
931 if (rc != SQLITE_ROW) {
932 sqlite3_finalize(stmt);
933 return std::nullopt;
934 }
935
936 auto record = parse_instance_row(stmt);
937 sqlite3_finalize(stmt);
938 return record;
939}
940
942 -> std::optional<instance_record> {
943 const char* sql = R"(
944 SELECT instance_pk, series_pk, sop_uid, sop_class_uid, instance_number,
945 transfer_syntax, content_date, content_time,
946 rows, columns, bits_allocated, number_of_frames,
947 file_path, file_size, file_hash, created_at
948 FROM instances
949 WHERE instance_pk = ?;
950 )";
951
952 sqlite3_stmt* stmt = nullptr;
953 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
954 if (rc != SQLITE_OK) {
955 return std::nullopt;
956 }
957
958 sqlite3_bind_int64(stmt, 1, pk);
959
960 rc = sqlite3_step(stmt);
961 if (rc != SQLITE_ROW) {
962 sqlite3_finalize(stmt);
963 return std::nullopt;
964 }
965
966 auto record = parse_instance_row(stmt);
967 sqlite3_finalize(stmt);
968 return record;
969}
970
971auto instance_repository::list_instances(std::string_view series_uid) const
973 std::vector<instance_record> results;
974
975 const char* sql = R"(
976 SELECT i.instance_pk, i.series_pk, i.sop_uid, i.sop_class_uid,
977 i.instance_number, i.transfer_syntax, i.content_date,
978 i.content_time, i.rows, i.columns, i.bits_allocated,
979 i.number_of_frames, i.file_path, i.file_size, i.file_hash,
980 i.created_at
981 FROM instances i
982 JOIN series s ON i.series_pk = s.series_pk
983 WHERE s.series_uid = ?
984 ORDER BY i.instance_number ASC, i.sop_uid ASC;
985 )";
986
987 sqlite3_stmt* stmt = nullptr;
988 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
989 if (rc != SQLITE_OK) {
990 return make_error<std::vector<instance_record>>(
992 kcenon::pacs::compat::format("Failed to prepare query: {}",
993 sqlite3_errmsg(db_)),
994 "storage");
995 }
996
997 sqlite3_bind_text(stmt, 1, series_uid.data(),
998 static_cast<int>(series_uid.size()), SQLITE_TRANSIENT);
999
1000 while (sqlite3_step(stmt) == SQLITE_ROW) {
1001 results.push_back(parse_instance_row(stmt));
1002 }
1003
1004 sqlite3_finalize(stmt);
1005 return ok(std::move(results));
1006}
1007
1010 std::vector<instance_record> results;
1011
1012 std::string sql = R"(
1013 SELECT i.instance_pk, i.series_pk, i.sop_uid, i.sop_class_uid,
1014 i.instance_number, i.transfer_syntax, i.content_date,
1015 i.content_time, i.rows, i.columns, i.bits_allocated,
1016 i.number_of_frames, i.file_path, i.file_size, i.file_hash,
1017 i.created_at
1018 FROM instances i
1019 JOIN series s ON i.series_pk = s.series_pk
1020 WHERE 1=1
1021 )";
1022
1023 std::vector<std::string> params;
1024
1025 if (query.series_uid.has_value()) {
1026 sql += " AND s.series_uid = ?";
1027 params.push_back(*query.series_uid);
1028 }
1029 if (query.sop_uid.has_value()) {
1030 sql += " AND i.sop_uid = ?";
1031 params.push_back(*query.sop_uid);
1032 }
1033 if (query.sop_class_uid.has_value()) {
1034 sql += " AND i.sop_class_uid = ?";
1035 params.push_back(*query.sop_class_uid);
1036 }
1037 if (query.content_date.has_value()) {
1038 sql += " AND i.content_date = ?";
1039 params.push_back(*query.content_date);
1040 }
1041 if (query.content_date_from.has_value()) {
1042 sql += " AND i.content_date >= ?";
1043 params.push_back(*query.content_date_from);
1044 }
1045 if (query.content_date_to.has_value()) {
1046 sql += " AND i.content_date <= ?";
1047 params.push_back(*query.content_date_to);
1048 }
1049
1050 sql += " ORDER BY i.instance_number ASC, i.sop_uid ASC";
1051
1052 if (query.limit > 0) {
1053 sql += kcenon::pacs::compat::format(" LIMIT {}", query.limit);
1054 }
1055 if (query.offset > 0) {
1056 sql += kcenon::pacs::compat::format(" OFFSET {}", query.offset);
1057 }
1058
1059 sqlite3_stmt* stmt = nullptr;
1060 auto rc = sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr);
1061 if (rc != SQLITE_OK) {
1062 return make_error<std::vector<instance_record>>(
1064 kcenon::pacs::compat::format("Failed to prepare query: {}",
1065 sqlite3_errmsg(db_)),
1066 "storage");
1067 }
1068
1069 int bind_index = 1;
1070 for (const auto& param : params) {
1071 sqlite3_bind_text(stmt, bind_index++, param.c_str(), -1,
1072 SQLITE_TRANSIENT);
1073 }
1074
1075 while (sqlite3_step(stmt) == SQLITE_ROW) {
1076 auto record = parse_instance_row(stmt);
1077 if (query.instance_number.has_value()) {
1078 if (!record.instance_number.has_value() ||
1079 *record.instance_number != *query.instance_number) {
1080 continue;
1081 }
1082 }
1083 results.push_back(std::move(record));
1084 }
1085
1086 sqlite3_finalize(stmt);
1087 return ok(std::move(results));
1088}
1089
1090auto instance_repository::delete_instance(std::string_view sop_uid)
1091 -> VoidResult {
1092 const char* sql = "DELETE FROM instances WHERE sop_uid = ?;";
1093
1094 sqlite3_stmt* stmt = nullptr;
1095 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1096 if (rc != SQLITE_OK) {
1097 return make_error<std::monostate>(
1098 rc,
1099 kcenon::pacs::compat::format("Failed to prepare delete: {}",
1100 sqlite3_errmsg(db_)),
1101 "storage");
1102 }
1103
1104 sqlite3_bind_text(stmt, 1, sop_uid.data(),
1105 static_cast<int>(sop_uid.size()), SQLITE_TRANSIENT);
1106
1107 rc = sqlite3_step(stmt);
1108 sqlite3_finalize(stmt);
1109
1110 if (rc != SQLITE_DONE) {
1111 return make_error<std::monostate>(
1112 rc,
1113 kcenon::pacs::compat::format("Failed to delete instance: {}",
1114 sqlite3_errmsg(db_)),
1115 "storage");
1116 }
1117
1118 return ok();
1119}
1120
1122 const char* sql = "SELECT COUNT(*) FROM instances;";
1123
1124 sqlite3_stmt* stmt = nullptr;
1125 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1126 if (rc != SQLITE_OK) {
1127 return make_error<size_t>(
1129 kcenon::pacs::compat::format("Failed to prepare query: {}",
1130 sqlite3_errmsg(db_)),
1131 "storage");
1132 }
1133
1134 size_t count = 0;
1135 if (sqlite3_step(stmt) == SQLITE_ROW) {
1136 count = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1137 }
1138
1139 sqlite3_finalize(stmt);
1140 return ok(count);
1141}
1142
1143auto instance_repository::instance_count(std::string_view series_uid) const
1144 -> Result<size_t> {
1145 const char* sql = R"(
1146 SELECT COUNT(*) FROM instances i
1147 JOIN series s ON i.series_pk = s.series_pk
1148 WHERE s.series_uid = ?;
1149 )";
1150
1151 sqlite3_stmt* stmt = nullptr;
1152 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1153 if (rc != SQLITE_OK) {
1154 return make_error<size_t>(
1156 kcenon::pacs::compat::format("Failed to prepare query: {}",
1157 sqlite3_errmsg(db_)),
1158 "storage");
1159 }
1160
1161 sqlite3_bind_text(stmt, 1, series_uid.data(),
1162 static_cast<int>(series_uid.size()), SQLITE_TRANSIENT);
1163
1164 size_t count = 0;
1165 if (sqlite3_step(stmt) == SQLITE_ROW) {
1166 count = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1167 }
1168
1169 sqlite3_finalize(stmt);
1170 return ok(count);
1171}
1172
1173auto instance_repository::get_file_path(std::string_view sop_instance_uid) const
1175 const char* sql = "SELECT file_path FROM instances WHERE sop_uid = ?;";
1176
1177 sqlite3_stmt* stmt = nullptr;
1178 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1179 if (rc != SQLITE_OK) {
1180 return make_error<std::optional<std::string>>(
1182 kcenon::pacs::compat::format("Failed to prepare query: {}",
1183 sqlite3_errmsg(db_)),
1184 "storage");
1185 }
1186
1187 sqlite3_bind_text(stmt, 1, sop_instance_uid.data(),
1188 static_cast<int>(sop_instance_uid.size()),
1189 SQLITE_TRANSIENT);
1190
1191 rc = sqlite3_step(stmt);
1192 if (rc != SQLITE_ROW) {
1193 sqlite3_finalize(stmt);
1194 return ok(std::optional<std::string>(std::nullopt));
1195 }
1196
1197 auto path = get_text(stmt, 0);
1198 sqlite3_finalize(stmt);
1199 return ok(std::optional<std::string>(path));
1200}
1201
1202auto instance_repository::get_study_files(std::string_view study_instance_uid) const
1204 std::vector<std::string> results;
1205
1206 const char* sql = R"(
1207 SELECT i.file_path
1208 FROM instances i
1209 JOIN series se ON i.series_pk = se.series_pk
1210 JOIN studies st ON se.study_pk = st.study_pk
1211 WHERE st.study_uid = ?
1212 ORDER BY se.series_number, i.instance_number;
1213 )";
1214
1215 sqlite3_stmt* stmt = nullptr;
1216 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1217 if (rc != SQLITE_OK) {
1218 return make_error<std::vector<std::string>>(
1220 kcenon::pacs::compat::format("Failed to prepare query: {}",
1221 sqlite3_errmsg(db_)),
1222 "storage");
1223 }
1224
1225 sqlite3_bind_text(stmt, 1, study_instance_uid.data(),
1226 static_cast<int>(study_instance_uid.size()),
1227 SQLITE_TRANSIENT);
1228
1229 while (sqlite3_step(stmt) == SQLITE_ROW) {
1230 results.push_back(get_text(stmt, 0));
1231 }
1232
1233 sqlite3_finalize(stmt);
1234 return ok(std::move(results));
1235}
1236
1237auto instance_repository::get_series_files(std::string_view series_instance_uid) const
1239 std::vector<std::string> results;
1240
1241 const char* sql = R"(
1242 SELECT i.file_path
1243 FROM instances i
1244 JOIN series se ON i.series_pk = se.series_pk
1245 WHERE se.series_uid = ?
1246 ORDER BY i.instance_number;
1247 )";
1248
1249 sqlite3_stmt* stmt = nullptr;
1250 auto rc = sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr);
1251 if (rc != SQLITE_OK) {
1252 return make_error<std::vector<std::string>>(
1254 kcenon::pacs::compat::format("Failed to prepare query: {}",
1255 sqlite3_errmsg(db_)),
1256 "storage");
1257 }
1258
1259 sqlite3_bind_text(stmt, 1, series_instance_uid.data(),
1260 static_cast<int>(series_instance_uid.size()),
1261 SQLITE_TRANSIENT);
1262
1263 while (sqlite3_step(stmt) == SQLITE_ROW) {
1264 results.push_back(get_text(stmt, 0));
1265 }
1266
1267 sqlite3_finalize(stmt);
1268 return ok(std::move(results));
1269}
1270
1271} // namespace kcenon::pacs::storage
1272
1273#endif // PACS_WITH_DATABASE_SYSTEM
Repository for instance metadata persistence (legacy SQLite interface)
auto find_instance_by_pk(int64_t pk) const -> std::optional< instance_record >
Find an instance record by its database primary key.
auto parse_instance_row(void *stmt) const -> instance_record
auto instance_count() const -> Result< size_t >
Get the total number of instance records in the repository.
auto get_file_path(std::string_view sop_instance_uid) const -> Result< std::optional< std::string > >
Retrieve the file system path for a stored instance.
instance_repository(sqlite3 *db)
Construct an instance repository with a raw SQLite database handle.
auto upsert_instance(int64_t series_pk, std::string_view sop_uid, std::string_view sop_class_uid, std::string_view file_path, int64_t file_size, std::string_view transfer_syntax="", std::optional< int > instance_number=std::nullopt) -> Result< int64_t >
Insert or update an instance record by individual fields.
auto search_instances(const instance_query &query) const -> Result< std::vector< instance_record > >
Search for instance records matching the given query criteria.
auto get_study_files(std::string_view study_instance_uid) const -> Result< std::vector< std::string > >
Retrieve all file paths for instances belonging to a study.
auto list_instances(std::string_view series_uid) const -> Result< std::vector< instance_record > >
List all instance records belonging to a given series.
auto delete_instance(std::string_view sop_uid) -> VoidResult
Delete an instance record by its SOP Instance UID.
auto find_instance(std::string_view sop_uid) const -> std::optional< instance_record >
Find an instance record by its SOP Instance UID.
auto get_series_files(std::string_view series_instance_uid) const -> Result< std::vector< std::string > >
Retrieve all file paths for instances belonging to a series.
Compatibility header providing kcenon::pacs::compat::format as an alias for std::format.
Repository for instance metadata persistence using base_repository pattern.
constexpr dicom_tag instance_number
Instance 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.
Instance record from the database.