PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
prefetch_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
15
16#include <chrono>
17#include <cstring>
18#include <sstream>
19
20#ifdef PACS_WITH_DATABASE_SYSTEM
21
22// =============================================================================
23// pacs_database_adapter Implementation
24// =============================================================================
25
26namespace kcenon::pacs::storage {
27
28namespace {
29
31[[nodiscard]] std::string to_timestamp_string(
32 std::chrono::system_clock::time_point tp) {
33 if (tp == std::chrono::system_clock::time_point{}) {
34 return "";
35 }
36 auto time = std::chrono::system_clock::to_time_t(tp);
37 std::tm tm{};
38#ifdef _WIN32
39 gmtime_s(&tm, &time);
40#else
41 gmtime_r(&time, &tm);
42#endif
43 char buf[32];
44 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
45 return buf;
46}
47
49[[nodiscard]] std::chrono::system_clock::time_point from_timestamp_string(
50 const std::string& str) {
51 if (str.empty()) {
52 return {};
53 }
54 std::tm tm{};
55 if (std::sscanf(str.c_str(), "%d-%d-%d %d:%d:%d",
56 &tm.tm_year, &tm.tm_mon, &tm.tm_mday,
57 &tm.tm_hour, &tm.tm_min, &tm.tm_sec) != 6) {
58 return {};
59 }
60 tm.tm_year -= 1900;
61 tm.tm_mon -= 1;
62#ifdef _WIN32
63 auto time = _mkgmtime(&tm);
64#else
65 auto time = timegm(&tm);
66#endif
67 return std::chrono::system_clock::from_time_t(time);
68}
69
71[[nodiscard]] std::string escape_json_string(const std::string& str) {
72 std::ostringstream oss;
73 for (char c : str) {
74 switch (c) {
75 case '"': oss << "\\\""; break;
76 case '\\': oss << "\\\\"; break;
77 case '\b': oss << "\\b"; break;
78 case '\f': oss << "\\f"; break;
79 case '\n': oss << "\\n"; break;
80 case '\r': oss << "\\r"; break;
81 case '\t': oss << "\\t"; break;
82 default: oss << c; break;
83 }
84 }
85 return oss.str();
86}
87
89[[nodiscard]] std::string unescape_json_string(std::string_view str) {
90 std::string result;
91 result.reserve(str.size());
92 for (size_t i = 0; i < str.size(); ++i) {
93 if (str[i] == '\\' && i + 1 < str.size()) {
94 ++i;
95 switch (str[i]) {
96 case '"': result += '"'; break;
97 case '\\': result += '\\'; break;
98 case 'b': result += '\b'; break;
99 case 'f': result += '\f'; break;
100 case 'n': result += '\n'; break;
101 case 'r': result += '\r'; break;
102 case 't': result += '\t'; break;
103 default: result += str[i]; break;
104 }
105 } else {
106 result += str[i];
107 }
108 }
109 return result;
110}
111
113[[nodiscard]] std::pair<std::string, size_t> extract_json_string(
114 std::string_view json, size_t pos) {
115 auto start = json.find('"', pos);
116 if (start == std::string_view::npos) return {"", std::string_view::npos};
117
118 size_t end = start + 1;
119 while (end < json.size()) {
120 if (json[end] == '\\' && end + 1 < json.size()) {
121 end += 2;
122 } else if (json[end] == '"') {
123 break;
124 } else {
125 ++end;
126 }
127 }
128
129 if (end >= json.size()) return {"", std::string_view::npos};
130
131 auto value = unescape_json_string(json.substr(start + 1, end - start - 1));
132 return {value, end + 1};
133}
134
135} // namespace
136
137// =============================================================================
138// JSON Serialization
139// =============================================================================
140
142 const std::vector<std::string>& modalities) {
143 if (modalities.empty()) return "[]";
144
145 std::ostringstream oss;
146 oss << "[";
147 for (size_t i = 0; i < modalities.size(); ++i) {
148 if (i > 0) oss << ",";
149 oss << "\"" << escape_json_string(modalities[i]) << "\"";
150 }
151 oss << "]";
152 return oss.str();
153}
154
155std::vector<std::string> prefetch_repository::deserialize_modalities(
156 std::string_view json) {
157 std::vector<std::string> result;
158 if (json.empty() || json == "[]") return result;
159
160 size_t pos = 0;
161 while (pos < json.size()) {
162 auto [value, next_pos] = extract_json_string(json, pos);
163 if (next_pos == std::string_view::npos) break;
164 if (!value.empty()) {
165 result.push_back(value);
166 }
167 pos = next_pos;
168 }
169
170 return result;
171}
172
174 const std::vector<std::string>& node_ids) {
175 return serialize_modalities(node_ids);
176}
177
178std::vector<std::string> prefetch_repository::deserialize_node_ids(
179 std::string_view json) {
180 return deserialize_modalities(json);
181}
182
183// =============================================================================
184// Construction / Destruction
185// =============================================================================
186
187prefetch_repository::prefetch_repository(std::shared_ptr<pacs_database_adapter> db)
188 : db_(std::move(db)) {}
189
190prefetch_repository::~prefetch_repository() = default;
191
192prefetch_repository::prefetch_repository(prefetch_repository&&) noexcept = default;
193
194auto prefetch_repository::operator=(prefetch_repository&&) noexcept
195 -> prefetch_repository& = default;
196
197// =============================================================================
198// Timestamp Helpers
199// =============================================================================
200
201auto prefetch_repository::parse_timestamp(const std::string& str) const
202 -> std::chrono::system_clock::time_point {
203 return from_timestamp_string(str);
204}
205
206auto prefetch_repository::format_timestamp(
207 std::chrono::system_clock::time_point tp) const -> std::string {
208 return to_timestamp_string(tp);
209}
210
211// =============================================================================
212// Database Initialization
213// =============================================================================
214
215VoidResult prefetch_repository::initialize_tables() {
216 if (!db_ || !db_->is_connected()) {
217 return VoidResult(kcenon::common::error_info{
218 -1, "Database not connected", "prefetch_repository"});
219 }
220
221 auto result = db_->open_session().execute(R"(
222 CREATE TABLE IF NOT EXISTS prefetch_rules (
223 pk INTEGER PRIMARY KEY AUTOINCREMENT,
224 rule_id TEXT UNIQUE NOT NULL,
225 name TEXT NOT NULL,
226 enabled INTEGER DEFAULT 1,
227 trigger_type TEXT NOT NULL,
228 modality_filter TEXT,
229 body_part_filter TEXT,
230 station_ae_filter TEXT,
231 prior_lookback_hours INTEGER DEFAULT 8760,
232 max_prior_studies INTEGER DEFAULT 3,
233 prior_modalities_json TEXT,
234 source_node_ids_json TEXT NOT NULL,
235 schedule_cron TEXT,
236 advance_time_minutes INTEGER DEFAULT 60,
237 triggered_count INTEGER DEFAULT 0,
238 studies_prefetched INTEGER DEFAULT 0,
239 last_triggered TIMESTAMP,
240 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
241 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
242 )
243 )");
244
245 if (result.is_err()) {
246 return result;
247 }
248
249 result = db_->open_session().execute(R"(
250 CREATE TABLE IF NOT EXISTS prefetch_history (
251 pk INTEGER PRIMARY KEY AUTOINCREMENT,
252 patient_id TEXT NOT NULL,
253 study_uid TEXT NOT NULL,
254 rule_id TEXT,
255 source_node_id TEXT NOT NULL,
256 job_id TEXT,
257 status TEXT NOT NULL,
258 prefetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
259 )
260 )");
261
262 if (result.is_err()) {
263 return result;
264 }
265
266 result = db_->open_session().execute(R"(
267 CREATE INDEX IF NOT EXISTS idx_prefetch_history_patient ON prefetch_history(patient_id);
268 CREATE INDEX IF NOT EXISTS idx_prefetch_history_study ON prefetch_history(study_uid);
269 CREATE INDEX IF NOT EXISTS idx_prefetch_history_status ON prefetch_history(status);
270 )");
271
272 return result;
273}
274
275// =============================================================================
276// Rule CRUD Operations
277// =============================================================================
278
279VoidResult prefetch_repository::save_rule(const client::prefetch_rule& rule) {
280 if (!db_ || !db_->is_connected()) {
281 return VoidResult(kcenon::common::error_info{
282 -1, "Database not connected", "prefetch_repository"});
283 }
284
285 std::ostringstream sql;
286 sql << R"(
287 INSERT INTO prefetch_rules (
288 rule_id, name, enabled, trigger_type,
289 modality_filter, body_part_filter, station_ae_filter,
290 prior_lookback_hours, max_prior_studies, prior_modalities_json,
291 source_node_ids_json, schedule_cron, advance_time_minutes,
292 triggered_count, studies_prefetched, last_triggered
293 ) VALUES (
294 ')" << rule.rule_id << "', "
295 << "'" << rule.name << "', "
296 << (rule.enabled ? 1 : 0) << ", "
297 << "'" << client::to_string(rule.trigger) << "', ";
298
299 if (rule.modality_filter.empty()) {
300 sql << "NULL, ";
301 } else {
302 sql << "'" << rule.modality_filter << "', ";
303 }
304
305 if (rule.body_part_filter.empty()) {
306 sql << "NULL, ";
307 } else {
308 sql << "'" << rule.body_part_filter << "', ";
309 }
310
311 if (rule.station_ae_filter.empty()) {
312 sql << "NULL, ";
313 } else {
314 sql << "'" << rule.station_ae_filter << "', ";
315 }
316
317 sql << rule.prior_lookback.count() << ", "
318 << rule.max_prior_studies << ", "
319 << "'" << serialize_modalities(rule.prior_modalities) << "', "
320 << "'" << serialize_node_ids(rule.source_node_ids) << "', ";
321
322 if (rule.schedule_cron.empty()) {
323 sql << "NULL, ";
324 } else {
325 sql << "'" << rule.schedule_cron << "', ";
326 }
327
328 sql << rule.advance_time.count() << ", "
329 << rule.triggered_count << ", "
330 << rule.studies_prefetched << ", ";
331
332 auto last_triggered_str = format_timestamp(rule.last_triggered);
333 if (last_triggered_str.empty()) {
334 sql << "NULL";
335 } else {
336 sql << "'" << last_triggered_str << "'";
337 }
338
339 sql << R"()
340 ON CONFLICT(rule_id) DO UPDATE SET
341 name = excluded.name,
342 enabled = excluded.enabled,
343 trigger_type = excluded.trigger_type,
344 modality_filter = excluded.modality_filter,
345 body_part_filter = excluded.body_part_filter,
346 station_ae_filter = excluded.station_ae_filter,
347 prior_lookback_hours = excluded.prior_lookback_hours,
348 max_prior_studies = excluded.max_prior_studies,
349 prior_modalities_json = excluded.prior_modalities_json,
350 source_node_ids_json = excluded.source_node_ids_json,
351 schedule_cron = excluded.schedule_cron,
352 advance_time_minutes = excluded.advance_time_minutes,
353 updated_at = CURRENT_TIMESTAMP
354 )";
355
356 auto result = db_->open_session().insert(sql.str());
357 if (result.is_err()) {
358 return VoidResult(result.error());
359 }
360
361 return kcenon::common::ok();
362}
363
364std::optional<client::prefetch_rule> prefetch_repository::find_rule_by_id(
365 std::string_view rule_id) const {
366 if (!db_ || !db_->is_connected()) return std::nullopt;
367
368 std::ostringstream sql;
369 sql << R"(
370 SELECT pk, rule_id, name, enabled, trigger_type,
371 modality_filter, body_part_filter, station_ae_filter,
372 prior_lookback_hours, max_prior_studies, prior_modalities_json,
373 source_node_ids_json, schedule_cron, advance_time_minutes,
374 triggered_count, studies_prefetched, last_triggered
375 FROM prefetch_rules WHERE rule_id = ')" << rule_id << "'";
376
377 auto result = db_->open_session().select(sql.str());
378 if (result.is_err() || result.value().empty()) {
379 return std::nullopt;
380 }
381
382 return map_row_to_rule(result.value()[0]);
383}
384
385std::optional<client::prefetch_rule> prefetch_repository::find_rule_by_pk(
386 int64_t pk) const {
387 if (!db_ || !db_->is_connected()) return std::nullopt;
388
389 std::ostringstream sql;
390 sql << R"(
391 SELECT pk, rule_id, name, enabled, trigger_type,
392 modality_filter, body_part_filter, station_ae_filter,
393 prior_lookback_hours, max_prior_studies, prior_modalities_json,
394 source_node_ids_json, schedule_cron, advance_time_minutes,
395 triggered_count, studies_prefetched, last_triggered
396 FROM prefetch_rules WHERE pk = )" << pk;
397
398 auto result = db_->open_session().select(sql.str());
399 if (result.is_err() || result.value().empty()) {
400 return std::nullopt;
401 }
402
403 return map_row_to_rule(result.value()[0]);
404}
405
406std::vector<client::prefetch_rule> prefetch_repository::find_rules(
407 const prefetch_rule_query_options& options) const {
408 std::vector<client::prefetch_rule> rules;
409 if (!db_ || !db_->is_connected()) return rules;
410
411 std::ostringstream sql;
412 sql << R"(
413 SELECT pk, rule_id, name, enabled, trigger_type,
414 modality_filter, body_part_filter, station_ae_filter,
415 prior_lookback_hours, max_prior_studies, prior_modalities_json,
416 source_node_ids_json, schedule_cron, advance_time_minutes,
417 triggered_count, studies_prefetched, last_triggered
418 FROM prefetch_rules WHERE 1=1
419 )";
420
421 if (options.enabled_only.has_value()) {
422 sql << " AND enabled = " << (options.enabled_only.value() ? "1" : "0");
423 }
424
425 if (options.trigger.has_value()) {
426 sql << " AND trigger_type = '" << client::to_string(options.trigger.value()) << "'";
427 }
428
429 sql << " ORDER BY created_at DESC";
430 sql << " LIMIT " << options.limit << " OFFSET " << options.offset;
431
432 auto result = db_->open_session().select(sql.str());
433 if (result.is_err()) return rules;
434
435 rules.reserve(result.value().size());
436 for (const auto& row : result.value()) {
437 rules.push_back(map_row_to_rule(row));
438 }
439
440 return rules;
441}
442
443std::vector<client::prefetch_rule> prefetch_repository::find_enabled_rules() const {
444 prefetch_rule_query_options options;
445 options.enabled_only = true;
446 return find_rules(options);
447}
448
449VoidResult prefetch_repository::remove_rule(std::string_view rule_id) {
450 if (!db_ || !db_->is_connected()) {
451 return VoidResult(kcenon::common::error_info{
452 -1, "Database not connected", "prefetch_repository"});
453 }
454
455 std::ostringstream sql;
456 sql << "DELETE FROM prefetch_rules WHERE rule_id = '" << rule_id << "'";
457
458 auto result = db_->open_session().remove(sql.str());
459 if (result.is_err()) {
460 return VoidResult(result.error());
461 }
462
463 return kcenon::common::ok();
464}
465
466bool prefetch_repository::rule_exists(std::string_view rule_id) const {
467 if (!db_ || !db_->is_connected()) return false;
468
469 std::ostringstream sql;
470 sql << "SELECT 1 FROM prefetch_rules WHERE rule_id = '" << rule_id << "'";
471
472 auto result = db_->open_session().select(sql.str());
473 return result.is_ok() && !result.value().empty();
474}
475
476// =============================================================================
477// Rule Statistics
478// =============================================================================
479
480VoidResult prefetch_repository::increment_triggered(std::string_view rule_id) {
481 if (!db_ || !db_->is_connected()) {
482 return VoidResult(kcenon::common::error_info{
483 -1, "Database not connected", "prefetch_repository"});
484 }
485
486 std::ostringstream sql;
487 sql << R"(
488 UPDATE prefetch_rules SET
489 triggered_count = triggered_count + 1,
490 last_triggered = CURRENT_TIMESTAMP
491 WHERE rule_id = ')" << rule_id << "'";
492
493 auto result = db_->open_session().update(sql.str());
494 if (result.is_err()) {
495 return VoidResult(result.error());
496 }
497
498 return kcenon::common::ok();
499}
500
501VoidResult prefetch_repository::increment_studies_prefetched(
502 std::string_view rule_id, size_t count) {
503 if (!db_ || !db_->is_connected()) {
504 return VoidResult(kcenon::common::error_info{
505 -1, "Database not connected", "prefetch_repository"});
506 }
507
508 std::ostringstream sql;
509 sql << R"(
510 UPDATE prefetch_rules SET
511 studies_prefetched = studies_prefetched + )" << count
512 << " WHERE rule_id = '" << rule_id << "'";
513
514 auto result = db_->open_session().update(sql.str());
515 if (result.is_err()) {
516 return VoidResult(result.error());
517 }
518
519 return kcenon::common::ok();
520}
521
522VoidResult prefetch_repository::enable_rule(std::string_view rule_id) {
523 if (!db_ || !db_->is_connected()) {
524 return VoidResult(kcenon::common::error_info{
525 -1, "Database not connected", "prefetch_repository"});
526 }
527
528 std::ostringstream sql;
529 sql << R"(
530 UPDATE prefetch_rules SET
531 enabled = 1,
532 updated_at = CURRENT_TIMESTAMP
533 WHERE rule_id = ')" << rule_id << "'";
534
535 auto result = db_->open_session().update(sql.str());
536 if (result.is_err()) {
537 return VoidResult(result.error());
538 }
539
540 return kcenon::common::ok();
541}
542
543VoidResult prefetch_repository::disable_rule(std::string_view rule_id) {
544 if (!db_ || !db_->is_connected()) {
545 return VoidResult(kcenon::common::error_info{
546 -1, "Database not connected", "prefetch_repository"});
547 }
548
549 std::ostringstream sql;
550 sql << R"(
551 UPDATE prefetch_rules SET
552 enabled = 0,
553 updated_at = CURRENT_TIMESTAMP
554 WHERE rule_id = ')" << rule_id << "'";
555
556 auto result = db_->open_session().update(sql.str());
557 if (result.is_err()) {
558 return VoidResult(result.error());
559 }
560
561 return kcenon::common::ok();
562}
563
564// =============================================================================
565// History Operations
566// =============================================================================
567
568VoidResult prefetch_repository::save_history(const client::prefetch_history& history) {
569 if (!db_ || !db_->is_connected()) {
570 return VoidResult(kcenon::common::error_info{
571 -1, "Database not connected", "prefetch_repository"});
572 }
573
574 std::ostringstream sql;
575 sql << R"(
576 INSERT INTO prefetch_history (
577 patient_id, study_uid, rule_id, source_node_id, job_id, status
578 ) VALUES (
579 ')" << history.patient_id << "', "
580 << "'" << history.study_uid << "', ";
581
582 if (history.rule_id.empty()) {
583 sql << "NULL, ";
584 } else {
585 sql << "'" << history.rule_id << "', ";
586 }
587
588 sql << "'" << history.source_node_id << "', ";
589
590 if (history.job_id.empty()) {
591 sql << "NULL, ";
592 } else {
593 sql << "'" << history.job_id << "', ";
594 }
595
596 sql << "'" << history.status << "')";
597
598 auto result = db_->open_session().insert(sql.str());
599 if (result.is_err()) {
600 return VoidResult(result.error());
601 }
602
603 return kcenon::common::ok();
604}
605
606std::vector<client::prefetch_history> prefetch_repository::find_history(
607 const prefetch_history_query_options& options) const {
608 std::vector<client::prefetch_history> histories;
609 if (!db_ || !db_->is_connected()) return histories;
610
611 std::ostringstream sql;
612 sql << R"(
613 SELECT pk, patient_id, study_uid, rule_id, source_node_id, job_id, status, prefetched_at
614 FROM prefetch_history WHERE 1=1
615 )";
616
617 if (options.patient_id.has_value()) {
618 sql << " AND patient_id = '" << options.patient_id.value() << "'";
619 }
620
621 if (options.rule_id.has_value()) {
622 sql << " AND rule_id = '" << options.rule_id.value() << "'";
623 }
624
625 if (options.status.has_value()) {
626 sql << " AND status = '" << options.status.value() << "'";
627 }
628
629 sql << " ORDER BY prefetched_at DESC";
630 sql << " LIMIT " << options.limit << " OFFSET " << options.offset;
631
632 auto result = db_->open_session().select(sql.str());
633 if (result.is_err()) return histories;
634
635 histories.reserve(result.value().size());
636 for (const auto& row : result.value()) {
637 histories.push_back(map_row_to_history(row));
638 }
639
640 return histories;
641}
642
643bool prefetch_repository::is_study_prefetched(std::string_view study_uid) const {
644 if (!db_ || !db_->is_connected()) return false;
645
646 std::ostringstream sql;
647 sql << R"(
648 SELECT 1 FROM prefetch_history
649 WHERE study_uid = ')" << study_uid << "' AND status IN ('completed', 'pending')";
650
651 auto result = db_->open_session().select(sql.str());
652 return result.is_ok() && !result.value().empty();
653}
654
655size_t prefetch_repository::count_completed_today() const {
656 if (!db_ || !db_->is_connected()) return 0;
657
658 auto result = db_->open_session().select(R"(
659 SELECT COUNT(*) as count FROM prefetch_history
660 WHERE status = 'completed'
661 AND date(prefetched_at) = date('now')
662 )");
663
664 if (result.is_err() || result.value().empty()) return 0;
665 return std::stoull(result.value()[0].at("count"));
666}
667
668size_t prefetch_repository::count_failed_today() const {
669 if (!db_ || !db_->is_connected()) return 0;
670
671 auto result = db_->open_session().select(R"(
672 SELECT COUNT(*) as count FROM prefetch_history
673 WHERE status = 'failed'
674 AND date(prefetched_at) = date('now')
675 )");
676
677 if (result.is_err() || result.value().empty()) return 0;
678 return std::stoull(result.value()[0].at("count"));
679}
680
681VoidResult prefetch_repository::update_history_status(
682 std::string_view study_uid,
683 std::string_view status) {
684 if (!db_ || !db_->is_connected()) {
685 return VoidResult(kcenon::common::error_info{
686 -1, "Database not connected", "prefetch_repository"});
687 }
688
689 std::ostringstream sql;
690 sql << "UPDATE prefetch_history SET status = '" << status
691 << "' WHERE study_uid = '" << study_uid << "'";
692
693 auto result = db_->open_session().update(sql.str());
694 if (result.is_err()) {
695 return VoidResult(result.error());
696 }
697
698 return kcenon::common::ok();
699}
700
701Result<size_t> prefetch_repository::cleanup_old_history(std::chrono::hours max_age) {
702 if (!db_ || !db_->is_connected()) {
703 return Result<size_t>(kcenon::common::error_info{
704 -1, "Database not connected", "prefetch_repository"});
705 }
706
707 auto cutoff = std::chrono::system_clock::now() - max_age;
708 auto cutoff_str = format_timestamp(cutoff);
709
710 std::ostringstream sql;
711 sql << "DELETE FROM prefetch_history WHERE prefetched_at < '" << cutoff_str << "'";
712
713 auto result = db_->open_session().remove(sql.str());
714 if (result.is_err()) {
715 return Result<size_t>(result.error());
716 }
717
718 return kcenon::common::ok(static_cast<size_t>(result.value()));
719}
720
721// =============================================================================
722// Statistics
723// =============================================================================
724
725size_t prefetch_repository::rule_count() const {
726 if (!db_ || !db_->is_connected()) return 0;
727
728 auto result = db_->open_session().select("SELECT COUNT(*) as count FROM prefetch_rules");
729 if (result.is_err() || result.value().empty()) return 0;
730 return std::stoull(result.value()[0].at("count"));
731}
732
733size_t prefetch_repository::enabled_rule_count() const {
734 if (!db_ || !db_->is_connected()) return 0;
735
736 auto result = db_->open_session().select(
737 "SELECT COUNT(*) as count FROM prefetch_rules WHERE enabled = 1");
738 if (result.is_err() || result.value().empty()) return 0;
739 return std::stoull(result.value()[0].at("count"));
740}
741
742size_t prefetch_repository::history_count() const {
743 if (!db_ || !db_->is_connected()) return 0;
744
745 auto result = db_->open_session().select("SELECT COUNT(*) as count FROM prefetch_history");
746 if (result.is_err() || result.value().empty()) return 0;
747 return std::stoull(result.value()[0].at("count"));
748}
749
750// =============================================================================
751// Database Information
752// =============================================================================
753
754bool prefetch_repository::is_valid() const noexcept {
755 return db_ && db_->is_connected();
756}
757
758// =============================================================================
759// Row Mapping
760// =============================================================================
761
762client::prefetch_rule prefetch_repository::map_row_to_rule(
763 const database_row& row) const {
764 client::prefetch_rule rule;
765
766 rule.pk = std::stoll(row.at("pk"));
767 rule.rule_id = row.at("rule_id");
768 rule.name = row.at("name");
769 rule.enabled = (row.at("enabled") == "1");
770 rule.trigger = client::prefetch_trigger_from_string(row.at("trigger_type"));
771
772 auto modality_it = row.find("modality_filter");
773 if (modality_it != row.end()) {
774 rule.modality_filter = modality_it->second;
775 }
776
777 auto body_part_it = row.find("body_part_filter");
778 if (body_part_it != row.end()) {
779 rule.body_part_filter = body_part_it->second;
780 }
781
782 auto station_it = row.find("station_ae_filter");
783 if (station_it != row.end()) {
784 rule.station_ae_filter = station_it->second;
785 }
786
787 rule.prior_lookback = std::chrono::hours{
788 std::stoll(row.at("prior_lookback_hours"))};
789 rule.max_prior_studies = std::stoull(row.at("max_prior_studies"));
790 rule.prior_modalities = deserialize_modalities(row.at("prior_modalities_json"));
791 rule.source_node_ids = deserialize_node_ids(row.at("source_node_ids_json"));
792
793 auto schedule_it = row.find("schedule_cron");
794 if (schedule_it != row.end()) {
795 rule.schedule_cron = schedule_it->second;
796 }
797
798 rule.advance_time = std::chrono::minutes{
799 std::stoll(row.at("advance_time_minutes"))};
800 rule.triggered_count = std::stoull(row.at("triggered_count"));
801 rule.studies_prefetched = std::stoull(row.at("studies_prefetched"));
802
803 auto last_triggered_it = row.find("last_triggered");
804 if (last_triggered_it != row.end() && !last_triggered_it->second.empty()) {
805 rule.last_triggered = parse_timestamp(last_triggered_it->second);
806 }
807
808 return rule;
809}
810
811client::prefetch_history prefetch_repository::map_row_to_history(
812 const database_row& row) const {
813 client::prefetch_history history;
814
815 history.pk = std::stoll(row.at("pk"));
816 history.patient_id = row.at("patient_id");
817 history.study_uid = row.at("study_uid");
818
819 auto rule_id_it = row.find("rule_id");
820 if (rule_id_it != row.end()) {
821 history.rule_id = rule_id_it->second;
822 }
823
824 history.source_node_id = row.at("source_node_id");
825
826 auto job_id_it = row.find("job_id");
827 if (job_id_it != row.end()) {
828 history.job_id = job_id_it->second;
829 }
830
831 history.status = row.at("status");
832 history.prefetched_at = parse_timestamp(row.at("prefetched_at"));
833
834 return history;
835}
836
837} // namespace kcenon::pacs::storage
838
839#else // !PACS_WITH_DATABASE_SYSTEM
840
841// =============================================================================
842// Legacy SQLite Implementation
843// =============================================================================
844
845#include <sqlite3.h>
846
847namespace kcenon::pacs::storage {
848
849// =============================================================================
850// Helper Functions
851// =============================================================================
852
853namespace {
854
856[[nodiscard]] std::string to_timestamp_string(
857 std::chrono::system_clock::time_point tp) {
858 if (tp == std::chrono::system_clock::time_point{}) {
859 return "";
860 }
861 auto time = std::chrono::system_clock::to_time_t(tp);
862 std::tm tm{};
863#ifdef _WIN32
864 gmtime_s(&tm, &time);
865#else
866 gmtime_r(&time, &tm);
867#endif
868 char buf[32];
869 std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm);
870 return buf;
871}
872
874[[nodiscard]] std::chrono::system_clock::time_point from_timestamp_string(
875 const char* str) {
876 if (!str || str[0] == '\0') {
877 return {};
878 }
879 std::tm tm{};
880 if (std::sscanf(str, "%d-%d-%d %d:%d:%d",
881 &tm.tm_year, &tm.tm_mon, &tm.tm_mday,
882 &tm.tm_hour, &tm.tm_min, &tm.tm_sec) != 6) {
883 return {};
884 }
885 tm.tm_year -= 1900;
886 tm.tm_mon -= 1;
887#ifdef _WIN32
888 auto time = _mkgmtime(&tm);
889#else
890 auto time = timegm(&tm);
891#endif
892 return std::chrono::system_clock::from_time_t(time);
893}
894
896[[nodiscard]] std::string get_text_column(sqlite3_stmt* stmt, int col) {
897 auto text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
898 return text ? text : "";
899}
900
902[[nodiscard]] int get_int_column(sqlite3_stmt* stmt, int col, int default_val = 0) {
903 if (sqlite3_column_type(stmt, col) == SQLITE_NULL) {
904 return default_val;
905 }
906 return sqlite3_column_int(stmt, col);
907}
908
910[[nodiscard]] int64_t get_int64_column(sqlite3_stmt* stmt, int col, int64_t default_val = 0) {
911 if (sqlite3_column_type(stmt, col) == SQLITE_NULL) {
912 return default_val;
913 }
914 return sqlite3_column_int64(stmt, col);
915}
916
918[[nodiscard]] std::string escape_json_string(const std::string& str) {
919 std::ostringstream oss;
920 for (char c : str) {
921 switch (c) {
922 case '"': oss << "\\\""; break;
923 case '\\': oss << "\\\\"; break;
924 case '\b': oss << "\\b"; break;
925 case '\f': oss << "\\f"; break;
926 case '\n': oss << "\\n"; break;
927 case '\r': oss << "\\r"; break;
928 case '\t': oss << "\\t"; break;
929 default: oss << c; break;
930 }
931 }
932 return oss.str();
933}
934
936[[nodiscard]] std::string unescape_json_string(std::string_view str) {
937 std::string result;
938 result.reserve(str.size());
939 for (size_t i = 0; i < str.size(); ++i) {
940 if (str[i] == '\\' && i + 1 < str.size()) {
941 ++i;
942 switch (str[i]) {
943 case '"': result += '"'; break;
944 case '\\': result += '\\'; break;
945 case 'b': result += '\b'; break;
946 case 'f': result += '\f'; break;
947 case 'n': result += '\n'; break;
948 case 'r': result += '\r'; break;
949 case 't': result += '\t'; break;
950 default: result += str[i]; break;
951 }
952 } else {
953 result += str[i];
954 }
955 }
956 return result;
957}
958
960[[nodiscard]] std::pair<std::string, size_t> extract_json_string(
961 std::string_view json, size_t pos) {
962 auto start = json.find('"', pos);
963 if (start == std::string_view::npos) return {"", std::string_view::npos};
964
965 size_t end = start + 1;
966 while (end < json.size()) {
967 if (json[end] == '\\' && end + 1 < json.size()) {
968 end += 2;
969 } else if (json[end] == '"') {
970 break;
971 } else {
972 ++end;
973 }
974 }
975
976 if (end >= json.size()) return {"", std::string_view::npos};
977
978 auto value = unescape_json_string(json.substr(start + 1, end - start - 1));
979 return {value, end + 1};
980}
981
982} // namespace
983
984// =============================================================================
985// JSON Serialization for String Arrays
986// =============================================================================
987
989 const std::vector<std::string>& modalities) {
990 if (modalities.empty()) return "[]";
991
992 std::ostringstream oss;
993 oss << "[";
994 for (size_t i = 0; i < modalities.size(); ++i) {
995 if (i > 0) oss << ",";
996 oss << "\"" << escape_json_string(modalities[i]) << "\"";
997 }
998 oss << "]";
999 return oss.str();
1000}
1001
1003 std::string_view json) {
1004 std::vector<std::string> result;
1005 if (json.empty() || json == "[]") return result;
1006
1007 size_t pos = 0;
1008 while (pos < json.size()) {
1009 auto [value, next_pos] = extract_json_string(json, pos);
1010 if (next_pos == std::string_view::npos) break;
1011 if (!value.empty()) {
1012 result.push_back(value);
1013 }
1014 pos = next_pos;
1015 }
1016
1017 return result;
1018}
1019
1021 const std::vector<std::string>& node_ids) {
1022 return serialize_modalities(node_ids); // Same format
1023}
1024
1026 std::string_view json) {
1027 return deserialize_modalities(json); // Same format
1028}
1029
1030// =============================================================================
1031// Construction / Destruction
1032// =============================================================================
1033
1035
1037
1039
1040auto prefetch_repository::operator=(prefetch_repository&&) noexcept -> prefetch_repository& = default;
1041
1042// =============================================================================
1043// Database Initialization
1044// =============================================================================
1045
1046VoidResult prefetch_repository::initialize_tables() {
1047 if (!db_) {
1048 return VoidResult(kcenon::common::error_info{
1049 -1, "Database not initialized", "prefetch_repository"});
1050 }
1051
1052 // Create prefetch_rules table
1053 static constexpr const char* create_rules_sql = R"(
1054 CREATE TABLE IF NOT EXISTS prefetch_rules (
1055 pk INTEGER PRIMARY KEY AUTOINCREMENT,
1056 rule_id TEXT UNIQUE NOT NULL,
1057 name TEXT NOT NULL,
1058 enabled INTEGER DEFAULT 1,
1059 trigger_type TEXT NOT NULL,
1060 modality_filter TEXT,
1061 body_part_filter TEXT,
1062 station_ae_filter TEXT,
1063 prior_lookback_hours INTEGER DEFAULT 8760,
1064 max_prior_studies INTEGER DEFAULT 3,
1065 prior_modalities_json TEXT,
1066 source_node_ids_json TEXT NOT NULL,
1067 schedule_cron TEXT,
1068 advance_time_minutes INTEGER DEFAULT 60,
1069 triggered_count INTEGER DEFAULT 0,
1070 studies_prefetched INTEGER DEFAULT 0,
1071 last_triggered TIMESTAMP,
1072 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1073 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1074 )
1075 )";
1076
1077 char* err_msg = nullptr;
1078 int rc = sqlite3_exec(db_, create_rules_sql, nullptr, nullptr, &err_msg);
1079 if (rc != SQLITE_OK) {
1080 std::string error = err_msg ? err_msg : "Unknown error";
1081 sqlite3_free(err_msg);
1082 return VoidResult(kcenon::common::error_info{
1083 -1, "Failed to create prefetch_rules table: " + error,
1084 "prefetch_repository"});
1085 }
1086
1087 // Create prefetch_history table
1088 static constexpr const char* create_history_sql = R"(
1089 CREATE TABLE IF NOT EXISTS prefetch_history (
1090 pk INTEGER PRIMARY KEY AUTOINCREMENT,
1091 patient_id TEXT NOT NULL,
1092 study_uid TEXT NOT NULL,
1093 rule_id TEXT,
1094 source_node_id TEXT NOT NULL,
1095 job_id TEXT,
1096 status TEXT NOT NULL,
1097 prefetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1098 )
1099 )";
1100
1101 rc = sqlite3_exec(db_, create_history_sql, nullptr, nullptr, &err_msg);
1102 if (rc != SQLITE_OK) {
1103 std::string error = err_msg ? err_msg : "Unknown error";
1104 sqlite3_free(err_msg);
1105 return VoidResult(kcenon::common::error_info{
1106 -1, "Failed to create prefetch_history table: " + error,
1107 "prefetch_repository"});
1108 }
1109
1110 // Create indexes
1111 static constexpr const char* create_indexes_sql = R"(
1112 CREATE INDEX IF NOT EXISTS idx_prefetch_history_patient ON prefetch_history(patient_id);
1113 CREATE INDEX IF NOT EXISTS idx_prefetch_history_study ON prefetch_history(study_uid);
1114 CREATE INDEX IF NOT EXISTS idx_prefetch_history_status ON prefetch_history(status);
1115 )";
1116
1117 rc = sqlite3_exec(db_, create_indexes_sql, nullptr, nullptr, &err_msg);
1118 if (rc != SQLITE_OK) {
1119 std::string error = err_msg ? err_msg : "Unknown error";
1120 sqlite3_free(err_msg);
1121 return VoidResult(kcenon::common::error_info{
1122 -1, "Failed to create indexes: " + error,
1123 "prefetch_repository"});
1124 }
1125
1126 return kcenon::common::ok();
1127}
1128
1129// =============================================================================
1130// Rule CRUD Operations
1131// =============================================================================
1132
1134 if (!db_) {
1135 return VoidResult(kcenon::common::error_info{
1136 -1, "Database not initialized", "prefetch_repository"});
1137 }
1138
1139 static constexpr const char* sql = R"(
1140 INSERT INTO prefetch_rules (
1141 rule_id, name, enabled, trigger_type,
1142 modality_filter, body_part_filter, station_ae_filter,
1143 prior_lookback_hours, max_prior_studies, prior_modalities_json,
1144 source_node_ids_json, schedule_cron, advance_time_minutes,
1145 triggered_count, studies_prefetched, last_triggered
1146 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1147 ON CONFLICT(rule_id) DO UPDATE SET
1148 name = excluded.name,
1149 enabled = excluded.enabled,
1150 trigger_type = excluded.trigger_type,
1151 modality_filter = excluded.modality_filter,
1152 body_part_filter = excluded.body_part_filter,
1153 station_ae_filter = excluded.station_ae_filter,
1154 prior_lookback_hours = excluded.prior_lookback_hours,
1155 max_prior_studies = excluded.max_prior_studies,
1156 prior_modalities_json = excluded.prior_modalities_json,
1157 source_node_ids_json = excluded.source_node_ids_json,
1158 schedule_cron = excluded.schedule_cron,
1159 advance_time_minutes = excluded.advance_time_minutes,
1160 updated_at = CURRENT_TIMESTAMP
1161 )";
1162
1163 sqlite3_stmt* stmt = nullptr;
1164 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1165 return VoidResult(kcenon::common::error_info{
1166 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1167 "prefetch_repository"});
1168 }
1169
1170 int idx = 1;
1171 sqlite3_bind_text(stmt, idx++, rule.rule_id.c_str(), -1, SQLITE_TRANSIENT);
1172 sqlite3_bind_text(stmt, idx++, rule.name.c_str(), -1, SQLITE_TRANSIENT);
1173 sqlite3_bind_int(stmt, idx++, rule.enabled ? 1 : 0);
1174 sqlite3_bind_text(stmt, idx++, client::to_string(rule.trigger), -1, SQLITE_STATIC);
1175
1176 if (rule.modality_filter.empty()) {
1177 sqlite3_bind_null(stmt, idx++);
1178 } else {
1179 sqlite3_bind_text(stmt, idx++, rule.modality_filter.c_str(), -1, SQLITE_TRANSIENT);
1180 }
1181
1182 if (rule.body_part_filter.empty()) {
1183 sqlite3_bind_null(stmt, idx++);
1184 } else {
1185 sqlite3_bind_text(stmt, idx++, rule.body_part_filter.c_str(), -1, SQLITE_TRANSIENT);
1186 }
1187
1188 if (rule.station_ae_filter.empty()) {
1189 sqlite3_bind_null(stmt, idx++);
1190 } else {
1191 sqlite3_bind_text(stmt, idx++, rule.station_ae_filter.c_str(), -1, SQLITE_TRANSIENT);
1192 }
1193
1194 sqlite3_bind_int64(stmt, idx++, rule.prior_lookback.count());
1195 sqlite3_bind_int64(stmt, idx++, static_cast<int64_t>(rule.max_prior_studies));
1196
1197 auto modalities_json = serialize_modalities(rule.prior_modalities);
1198 sqlite3_bind_text(stmt, idx++, modalities_json.c_str(), -1, SQLITE_TRANSIENT);
1199
1200 auto node_ids_json = serialize_node_ids(rule.source_node_ids);
1201 sqlite3_bind_text(stmt, idx++, node_ids_json.c_str(), -1, SQLITE_TRANSIENT);
1202
1203 if (rule.schedule_cron.empty()) {
1204 sqlite3_bind_null(stmt, idx++);
1205 } else {
1206 sqlite3_bind_text(stmt, idx++, rule.schedule_cron.c_str(), -1, SQLITE_TRANSIENT);
1207 }
1208
1209 sqlite3_bind_int64(stmt, idx++, rule.advance_time.count());
1210 sqlite3_bind_int64(stmt, idx++, static_cast<int64_t>(rule.triggered_count));
1211 sqlite3_bind_int64(stmt, idx++, static_cast<int64_t>(rule.studies_prefetched));
1212
1213 auto last_triggered_str = to_timestamp_string(rule.last_triggered);
1214 if (last_triggered_str.empty()) {
1215 sqlite3_bind_null(stmt, idx++);
1216 } else {
1217 sqlite3_bind_text(stmt, idx++, last_triggered_str.c_str(), -1, SQLITE_TRANSIENT);
1218 }
1219
1220 auto rc = sqlite3_step(stmt);
1221 sqlite3_finalize(stmt);
1222
1223 if (rc != SQLITE_DONE) {
1224 return VoidResult(kcenon::common::error_info{
1225 -1, "Failed to save rule: " + std::string(sqlite3_errmsg(db_)),
1226 "prefetch_repository"});
1227 }
1228
1229 return kcenon::common::ok();
1230}
1231
1232std::optional<client::prefetch_rule> prefetch_repository::find_rule_by_id(
1233 std::string_view rule_id) const {
1234 if (!db_) return std::nullopt;
1235
1236 static constexpr const char* sql = R"(
1237 SELECT pk, rule_id, name, enabled, trigger_type,
1238 modality_filter, body_part_filter, station_ae_filter,
1239 prior_lookback_hours, max_prior_studies, prior_modalities_json,
1240 source_node_ids_json, schedule_cron, advance_time_minutes,
1241 triggered_count, studies_prefetched, last_triggered
1242 FROM prefetch_rules WHERE rule_id = ?
1243 )";
1244
1245 sqlite3_stmt* stmt = nullptr;
1246 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1247 return std::nullopt;
1248 }
1249
1250 sqlite3_bind_text(stmt, 1, rule_id.data(), static_cast<int>(rule_id.size()), SQLITE_TRANSIENT);
1251
1252 std::optional<client::prefetch_rule> result;
1253 if (sqlite3_step(stmt) == SQLITE_ROW) {
1254 result = parse_rule_row(stmt);
1255 }
1256
1257 sqlite3_finalize(stmt);
1258 return result;
1259}
1260
1261std::optional<client::prefetch_rule> prefetch_repository::find_rule_by_pk(int64_t pk) const {
1262 if (!db_) return std::nullopt;
1263
1264 static constexpr const char* sql = R"(
1265 SELECT pk, rule_id, name, enabled, trigger_type,
1266 modality_filter, body_part_filter, station_ae_filter,
1267 prior_lookback_hours, max_prior_studies, prior_modalities_json,
1268 source_node_ids_json, schedule_cron, advance_time_minutes,
1269 triggered_count, studies_prefetched, last_triggered
1270 FROM prefetch_rules WHERE pk = ?
1271 )";
1272
1273 sqlite3_stmt* stmt = nullptr;
1274 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1275 return std::nullopt;
1276 }
1277
1278 sqlite3_bind_int64(stmt, 1, pk);
1279
1280 std::optional<client::prefetch_rule> result;
1281 if (sqlite3_step(stmt) == SQLITE_ROW) {
1282 result = parse_rule_row(stmt);
1283 }
1284
1285 sqlite3_finalize(stmt);
1286 return result;
1287}
1288
1289std::vector<client::prefetch_rule> prefetch_repository::find_rules(
1290 const prefetch_rule_query_options& options) const {
1291 std::vector<client::prefetch_rule> result;
1292 if (!db_) return result;
1293
1294 std::ostringstream sql;
1295 sql << R"(
1296 SELECT pk, rule_id, name, enabled, trigger_type,
1297 modality_filter, body_part_filter, station_ae_filter,
1298 prior_lookback_hours, max_prior_studies, prior_modalities_json,
1299 source_node_ids_json, schedule_cron, advance_time_minutes,
1300 triggered_count, studies_prefetched, last_triggered
1301 FROM prefetch_rules WHERE 1=1
1302 )";
1303
1304 if (options.enabled_only.has_value()) {
1305 sql << " AND enabled = " << (options.enabled_only.value() ? "1" : "0");
1306 }
1307
1308 if (options.trigger.has_value()) {
1309 sql << " AND trigger_type = '" << client::to_string(options.trigger.value()) << "'";
1310 }
1311
1312 sql << " ORDER BY created_at DESC";
1313 sql << " LIMIT " << options.limit << " OFFSET " << options.offset;
1314
1315 sqlite3_stmt* stmt = nullptr;
1316 auto sql_str = sql.str();
1317 if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
1318 return result;
1319 }
1320
1321 while (sqlite3_step(stmt) == SQLITE_ROW) {
1322 result.push_back(parse_rule_row(stmt));
1323 }
1324
1325 sqlite3_finalize(stmt);
1326 return result;
1327}
1329std::vector<client::prefetch_rule> prefetch_repository::find_enabled_rules() const {
1331 options.enabled_only = true;
1332 return find_rules(options);
1333}
1334
1335VoidResult prefetch_repository::remove_rule(std::string_view rule_id) {
1336 if (!db_) {
1337 return VoidResult(kcenon::common::error_info{
1338 -1, "Database not initialized", "prefetch_repository"});
1339 }
1340
1341 static constexpr const char* sql = "DELETE FROM prefetch_rules WHERE rule_id = ?";
1342
1343 sqlite3_stmt* stmt = nullptr;
1344 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1345 return VoidResult(kcenon::common::error_info{
1346 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1347 "prefetch_repository"});
1348 }
1349
1350 sqlite3_bind_text(stmt, 1, rule_id.data(), static_cast<int>(rule_id.size()), SQLITE_TRANSIENT);
1351
1352 auto rc = sqlite3_step(stmt);
1353 sqlite3_finalize(stmt);
1354
1355 if (rc != SQLITE_DONE) {
1356 return VoidResult(kcenon::common::error_info{
1357 -1, "Failed to delete rule: " + std::string(sqlite3_errmsg(db_)),
1358 "prefetch_repository"});
1359 }
1360
1361 return kcenon::common::ok();
1362}
1363
1364bool prefetch_repository::rule_exists(std::string_view rule_id) const {
1365 if (!db_) return false;
1366
1367 static constexpr const char* sql = "SELECT 1 FROM prefetch_rules WHERE rule_id = ?";
1368
1369 sqlite3_stmt* stmt = nullptr;
1370 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1371 return false;
1372 }
1373
1374 sqlite3_bind_text(stmt, 1, rule_id.data(), static_cast<int>(rule_id.size()), SQLITE_TRANSIENT);
1375
1376 bool found = (sqlite3_step(stmt) == SQLITE_ROW);
1377 sqlite3_finalize(stmt);
1378 return found;
1379}
1380
1381// =============================================================================
1382// Rule Statistics
1383// =============================================================================
1384
1385VoidResult prefetch_repository::increment_triggered(std::string_view rule_id) {
1386 if (!db_) {
1387 return VoidResult(kcenon::common::error_info{
1388 -1, "Database not initialized", "prefetch_repository"});
1389 }
1390
1391 static constexpr const char* sql = R"(
1392 UPDATE prefetch_rules SET
1393 triggered_count = triggered_count + 1,
1394 last_triggered = CURRENT_TIMESTAMP
1395 WHERE rule_id = ?
1396 )";
1397
1398 sqlite3_stmt* stmt = nullptr;
1399 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1400 return VoidResult(kcenon::common::error_info{
1401 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1402 "prefetch_repository"});
1403 }
1404
1405 sqlite3_bind_text(stmt, 1, rule_id.data(), static_cast<int>(rule_id.size()), SQLITE_TRANSIENT);
1406
1407 auto rc = sqlite3_step(stmt);
1408 sqlite3_finalize(stmt);
1409
1410 if (rc != SQLITE_DONE) {
1411 return VoidResult(kcenon::common::error_info{
1412 -1, "Failed to increment triggered: " + std::string(sqlite3_errmsg(db_)),
1413 "prefetch_repository"});
1414 }
1415
1416 return kcenon::common::ok();
1417}
1418
1420 std::string_view rule_id, size_t count) {
1421 if (!db_) {
1422 return VoidResult(kcenon::common::error_info{
1423 -1, "Database not initialized", "prefetch_repository"});
1424 }
1425
1426 static constexpr const char* sql = R"(
1427 UPDATE prefetch_rules SET
1428 studies_prefetched = studies_prefetched + ?
1429 WHERE rule_id = ?
1430 )";
1431
1432 sqlite3_stmt* stmt = nullptr;
1433 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1434 return VoidResult(kcenon::common::error_info{
1435 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1436 "prefetch_repository"});
1437 }
1438
1439 sqlite3_bind_int64(stmt, 1, static_cast<int64_t>(count));
1440 sqlite3_bind_text(stmt, 2, rule_id.data(), static_cast<int>(rule_id.size()), SQLITE_TRANSIENT);
1441
1442 auto rc = sqlite3_step(stmt);
1443 sqlite3_finalize(stmt);
1444
1445 if (rc != SQLITE_DONE) {
1446 return VoidResult(kcenon::common::error_info{
1447 -1, "Failed to increment studies prefetched: " + std::string(sqlite3_errmsg(db_)),
1448 "prefetch_repository"});
1449 }
1450
1451 return kcenon::common::ok();
1452}
1453
1454VoidResult prefetch_repository::enable_rule(std::string_view rule_id) {
1455 if (!db_) {
1456 return VoidResult(kcenon::common::error_info{
1457 -1, "Database not initialized", "prefetch_repository"});
1458 }
1459
1460 static constexpr const char* sql = R"(
1461 UPDATE prefetch_rules SET
1462 enabled = 1,
1463 updated_at = CURRENT_TIMESTAMP
1464 WHERE rule_id = ?
1465 )";
1466
1467 sqlite3_stmt* stmt = nullptr;
1468 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1469 return VoidResult(kcenon::common::error_info{
1470 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1471 "prefetch_repository"});
1472 }
1473
1474 sqlite3_bind_text(stmt, 1, rule_id.data(), static_cast<int>(rule_id.size()), SQLITE_TRANSIENT);
1475
1476 auto rc = sqlite3_step(stmt);
1477 sqlite3_finalize(stmt);
1478
1479 if (rc != SQLITE_DONE) {
1480 return VoidResult(kcenon::common::error_info{
1481 -1, "Failed to enable rule: " + std::string(sqlite3_errmsg(db_)),
1482 "prefetch_repository"});
1483 }
1484
1485 return kcenon::common::ok();
1486}
1487
1488VoidResult prefetch_repository::disable_rule(std::string_view rule_id) {
1489 if (!db_) {
1490 return VoidResult(kcenon::common::error_info{
1491 -1, "Database not initialized", "prefetch_repository"});
1492 }
1493
1494 static constexpr const char* sql = R"(
1495 UPDATE prefetch_rules SET
1496 enabled = 0,
1497 updated_at = CURRENT_TIMESTAMP
1498 WHERE rule_id = ?
1499 )";
1500
1501 sqlite3_stmt* stmt = nullptr;
1502 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1503 return VoidResult(kcenon::common::error_info{
1504 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1505 "prefetch_repository"});
1506 }
1507
1508 sqlite3_bind_text(stmt, 1, rule_id.data(), static_cast<int>(rule_id.size()), SQLITE_TRANSIENT);
1509
1510 auto rc = sqlite3_step(stmt);
1511 sqlite3_finalize(stmt);
1512
1513 if (rc != SQLITE_DONE) {
1514 return VoidResult(kcenon::common::error_info{
1515 -1, "Failed to disable rule: " + std::string(sqlite3_errmsg(db_)),
1516 "prefetch_repository"});
1517 }
1518
1519 return kcenon::common::ok();
1520}
1521
1522// =============================================================================
1523// History Operations
1524// =============================================================================
1525
1527 if (!db_) {
1528 return VoidResult(kcenon::common::error_info{
1529 -1, "Database not initialized", "prefetch_repository"});
1530 }
1531
1532 static constexpr const char* sql = R"(
1533 INSERT INTO prefetch_history (
1534 patient_id, study_uid, rule_id, source_node_id, job_id, status
1535 ) VALUES (?, ?, ?, ?, ?, ?)
1536 )";
1537
1538 sqlite3_stmt* stmt = nullptr;
1539 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1540 return VoidResult(kcenon::common::error_info{
1541 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1542 "prefetch_repository"});
1543 }
1544
1545 int idx = 1;
1546 sqlite3_bind_text(stmt, idx++, history.patient_id.c_str(), -1, SQLITE_TRANSIENT);
1547 sqlite3_bind_text(stmt, idx++, history.study_uid.c_str(), -1, SQLITE_TRANSIENT);
1548
1549 if (history.rule_id.empty()) {
1550 sqlite3_bind_null(stmt, idx++);
1551 } else {
1552 sqlite3_bind_text(stmt, idx++, history.rule_id.c_str(), -1, SQLITE_TRANSIENT);
1553 }
1554
1555 sqlite3_bind_text(stmt, idx++, history.source_node_id.c_str(), -1, SQLITE_TRANSIENT);
1556
1557 if (history.job_id.empty()) {
1558 sqlite3_bind_null(stmt, idx++);
1559 } else {
1560 sqlite3_bind_text(stmt, idx++, history.job_id.c_str(), -1, SQLITE_TRANSIENT);
1561 }
1562
1563 sqlite3_bind_text(stmt, idx++, history.status.c_str(), -1, SQLITE_TRANSIENT);
1564
1565 auto rc = sqlite3_step(stmt);
1566 sqlite3_finalize(stmt);
1567
1568 if (rc != SQLITE_DONE) {
1569 return VoidResult(kcenon::common::error_info{
1570 -1, "Failed to save history: " + std::string(sqlite3_errmsg(db_)),
1571 "prefetch_repository"});
1572 }
1573
1574 return kcenon::common::ok();
1575}
1576
1577std::vector<client::prefetch_history> prefetch_repository::find_history(
1578 const prefetch_history_query_options& options) const {
1579 std::vector<client::prefetch_history> result;
1580 if (!db_) return result;
1581
1582 std::ostringstream sql;
1583 sql << R"(
1584 SELECT pk, patient_id, study_uid, rule_id, source_node_id, job_id, status, prefetched_at
1585 FROM prefetch_history WHERE 1=1
1586 )";
1587
1588 if (options.patient_id.has_value()) {
1589 sql << " AND patient_id = '" << options.patient_id.value() << "'";
1590 }
1591
1592 if (options.rule_id.has_value()) {
1593 sql << " AND rule_id = '" << options.rule_id.value() << "'";
1594 }
1595
1596 if (options.status.has_value()) {
1597 sql << " AND status = '" << options.status.value() << "'";
1598 }
1599
1600 sql << " ORDER BY prefetched_at DESC";
1601 sql << " LIMIT " << options.limit << " OFFSET " << options.offset;
1602
1603 sqlite3_stmt* stmt = nullptr;
1604 auto sql_str = sql.str();
1605 if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
1606 return result;
1608
1609 while (sqlite3_step(stmt) == SQLITE_ROW) {
1610 result.push_back(parse_history_row(stmt));
1611 }
1612
1613 sqlite3_finalize(stmt);
1614 return result;
1615}
1616
1617bool prefetch_repository::is_study_prefetched(std::string_view study_uid) const {
1618 if (!db_) return false;
1619
1620 static constexpr const char* sql = R"(
1621 SELECT 1 FROM prefetch_history
1622 WHERE study_uid = ? AND status IN ('completed', 'pending')
1623 )";
1624
1625 sqlite3_stmt* stmt = nullptr;
1626 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1627 return false;
1628 }
1629
1630 sqlite3_bind_text(stmt, 1, study_uid.data(), static_cast<int>(study_uid.size()), SQLITE_TRANSIENT);
1631
1632 bool found = (sqlite3_step(stmt) == SQLITE_ROW);
1633 sqlite3_finalize(stmt);
1634 return found;
1635}
1636
1638 if (!db_) return 0;
1639
1640 static constexpr const char* sql = R"(
1641 SELECT COUNT(*) FROM prefetch_history
1642 WHERE status = 'completed'
1643 AND date(prefetched_at) = date('now')
1644 )";
1645
1646 sqlite3_stmt* stmt = nullptr;
1647 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1648 return 0;
1649 }
1651 size_t result = 0;
1652 if (sqlite3_step(stmt) == SQLITE_ROW) {
1653 result = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1654 }
1655
1656 sqlite3_finalize(stmt);
1657 return result;
1658}
1659
1661 if (!db_) return 0;
1662
1663 static constexpr const char* sql = R"(
1664 SELECT COUNT(*) FROM prefetch_history
1665 WHERE status = 'failed'
1666 AND date(prefetched_at) = date('now')
1667 )";
1668
1669 sqlite3_stmt* stmt = nullptr;
1670 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1671 return 0;
1672 }
1674 size_t result = 0;
1675 if (sqlite3_step(stmt) == SQLITE_ROW) {
1676 result = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1677 }
1678
1679 sqlite3_finalize(stmt);
1680 return result;
1681}
1682
1684 std::string_view study_uid,
1685 std::string_view status) {
1686 if (!db_) {
1687 return VoidResult(kcenon::common::error_info{
1688 -1, "Database not initialized", "prefetch_repository"});
1689 }
1690
1691 static constexpr const char* sql = R"(
1692 UPDATE prefetch_history SET status = ? WHERE study_uid = ?
1693 )";
1694
1695 sqlite3_stmt* stmt = nullptr;
1696 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1697 return VoidResult(kcenon::common::error_info{
1698 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1699 "prefetch_repository"});
1700 }
1701
1702 sqlite3_bind_text(stmt, 1, status.data(), static_cast<int>(status.size()), SQLITE_TRANSIENT);
1703 sqlite3_bind_text(stmt, 2, study_uid.data(), static_cast<int>(study_uid.size()), SQLITE_TRANSIENT);
1704
1705 auto rc = sqlite3_step(stmt);
1706 sqlite3_finalize(stmt);
1708 if (rc != SQLITE_DONE) {
1709 return VoidResult(kcenon::common::error_info{
1710 -1, "Failed to update status: " + std::string(sqlite3_errmsg(db_)),
1711 "prefetch_repository"});
1712 }
1713
1714 return kcenon::common::ok();
1715}
1716
1717Result<size_t> prefetch_repository::cleanup_old_history(std::chrono::hours max_age) {
1718 if (!db_) {
1719 return Result<size_t>(kcenon::common::error_info{
1720 -1, "Database not initialized", "prefetch_repository"});
1721 }
1722
1723 // Calculate cutoff timestamp
1724 auto cutoff = std::chrono::system_clock::now() - max_age;
1725 auto cutoff_str = to_timestamp_string(cutoff);
1726
1727 static constexpr const char* sql = R"(
1728 DELETE FROM prefetch_history WHERE prefetched_at < ?
1729 )";
1730
1731 sqlite3_stmt* stmt = nullptr;
1732 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1733 return Result<size_t>(kcenon::common::error_info{
1734 -1, "Failed to prepare statement: " + std::string(sqlite3_errmsg(db_)),
1735 "prefetch_repository"});
1736 }
1737
1738 sqlite3_bind_text(stmt, 1, cutoff_str.c_str(), -1, SQLITE_TRANSIENT);
1739
1740 auto rc = sqlite3_step(stmt);
1741 sqlite3_finalize(stmt);
1742
1743 if (rc != SQLITE_DONE) {
1744 return Result<size_t>(kcenon::common::error_info{
1745 -1, "Failed to cleanup history: " + std::string(sqlite3_errmsg(db_)),
1746 "prefetch_repository"});
1747 }
1748
1749 return kcenon::common::ok(static_cast<size_t>(sqlite3_changes(db_)));
1750}
1751
1752// =============================================================================
1753// Statistics
1754// =============================================================================
1755
1756size_t prefetch_repository::rule_count() const {
1757 if (!db_) return 0;
1758
1759 static constexpr const char* sql = "SELECT COUNT(*) FROM prefetch_rules";
1760
1761 sqlite3_stmt* stmt = nullptr;
1762 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1763 return 0;
1764 }
1766 size_t result = 0;
1767 if (sqlite3_step(stmt) == SQLITE_ROW) {
1768 result = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1769 }
1770
1771 sqlite3_finalize(stmt);
1772 return result;
1773}
1774
1776 if (!db_) return 0;
1777
1778 static constexpr const char* sql = "SELECT COUNT(*) FROM prefetch_rules WHERE enabled = 1";
1779
1780 sqlite3_stmt* stmt = nullptr;
1781 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1782 return 0;
1783 }
1785 size_t result = 0;
1786 if (sqlite3_step(stmt) == SQLITE_ROW) {
1787 result = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1788 }
1789
1790 sqlite3_finalize(stmt);
1791 return result;
1792}
1793
1795 if (!db_) return 0;
1796
1797 static constexpr const char* sql = "SELECT COUNT(*) FROM prefetch_history";
1798
1799 sqlite3_stmt* stmt = nullptr;
1800 if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
1801 return 0;
1802 }
1803
1804 size_t result = 0;
1805 if (sqlite3_step(stmt) == SQLITE_ROW) {
1806 result = static_cast<size_t>(sqlite3_column_int64(stmt, 0));
1808
1809 sqlite3_finalize(stmt);
1810 return result;
1811}
1812
1813// =============================================================================
1814// Database Information
1815// =============================================================================
1816
1817bool prefetch_repository::is_valid() const noexcept {
1818 return db_ != nullptr;
1819}
1820
1821// =============================================================================
1822// Private Implementation
1823// =============================================================================
1824
1826 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
1828
1829 int col = 0;
1830 rule.pk = get_int64_column(stmt, col++);
1831 rule.rule_id = get_text_column(stmt, col++);
1832 rule.name = get_text_column(stmt, col++);
1833 rule.enabled = (get_int_column(stmt, col++) != 0);
1834 rule.trigger = client::prefetch_trigger_from_string(get_text_column(stmt, col++));
1835
1836 rule.modality_filter = get_text_column(stmt, col++);
1837 rule.body_part_filter = get_text_column(stmt, col++);
1838 rule.station_ae_filter = get_text_column(stmt, col++);
1839
1840 rule.prior_lookback = std::chrono::hours{get_int64_column(stmt, col++)};
1841 rule.max_prior_studies = static_cast<size_t>(get_int64_column(stmt, col++));
1842
1843 auto modalities_json = get_text_column(stmt, col++);
1844 rule.prior_modalities = deserialize_modalities(modalities_json);
1845
1846 auto node_ids_json = get_text_column(stmt, col++);
1847 rule.source_node_ids = deserialize_node_ids(node_ids_json);
1848
1849 rule.schedule_cron = get_text_column(stmt, col++);
1850 rule.advance_time = std::chrono::minutes{get_int64_column(stmt, col++)};
1852 rule.triggered_count = static_cast<size_t>(get_int64_column(stmt, col++));
1853 rule.studies_prefetched = static_cast<size_t>(get_int64_column(stmt, col++));
1854
1855 auto last_triggered_str = get_text_column(stmt, col++);
1856 rule.last_triggered = from_timestamp_string(last_triggered_str.c_str());
1857
1858 return rule;
1859}
1860
1862 auto* stmt = static_cast<sqlite3_stmt*>(stmt_ptr);
1864
1865 int col = 0;
1866 history.pk = get_int64_column(stmt, col++);
1867 history.patient_id = get_text_column(stmt, col++);
1868 history.study_uid = get_text_column(stmt, col++);
1869 history.rule_id = get_text_column(stmt, col++);
1870 history.source_node_id = get_text_column(stmt, col++);
1871 history.job_id = get_text_column(stmt, col++);
1872 history.status = get_text_column(stmt, col++);
1873
1874 auto prefetched_str = get_text_column(stmt, col++);
1875 history.prefetched_at = from_timestamp_string(prefetched_str.c_str());
1876
1877 return history;
1878}
1879
1880} // namespace kcenon::pacs::storage
1881
1882#endif // PACS_WITH_DATABASE_SYSTEM
Repository for prefetch persistence (legacy SQLite interface)
auto find_history(const prefetch_history_query_options &options={}) const -> std::vector< client::prefetch_history >
auto find_rules(const prefetch_rule_query_options &options={}) const -> std::vector< client::prefetch_rule >
auto find_rule_by_pk(int64_t pk) const -> std::optional< client::prefetch_rule >
auto update_history_status(std::string_view study_uid, std::string_view status) -> VoidResult
auto find_enabled_rules() const -> std::vector< client::prefetch_rule >
auto enable_rule(std::string_view rule_id) -> VoidResult
auto save_history(const client::prefetch_history &history) -> VoidResult
auto cleanup_old_history(std::chrono::hours max_age) -> Result< size_t >
auto rule_exists(std::string_view rule_id) const -> bool
auto increment_triggered(std::string_view rule_id) -> VoidResult
auto is_study_prefetched(std::string_view study_uid) const -> bool
static auto deserialize_node_ids(std::string_view json) -> std::vector< std::string >
auto parse_history_row(void *stmt) const -> client::prefetch_history
auto parse_rule_row(void *stmt) const -> client::prefetch_rule
auto save_rule(const client::prefetch_rule &rule) -> VoidResult
auto find_rule_by_id(std::string_view rule_id) const -> std::optional< client::prefetch_rule >
auto disable_rule(std::string_view rule_id) -> VoidResult
static auto serialize_node_ids(const std::vector< std::string > &node_ids) -> std::string
auto increment_studies_prefetched(std::string_view rule_id, size_t count=1) -> VoidResult
static auto serialize_modalities(const std::vector< std::string > &modalities) -> std::string
auto remove_rule(std::string_view rule_id) -> VoidResult
static auto deserialize_modalities(std::string_view json) -> std::vector< std::string >
prefetch_trigger prefetch_trigger_from_string(std::string_view str) noexcept
Parse prefetch_trigger from string.
constexpr const char * to_string(job_type type) noexcept
Convert job_type to string representation.
Definition job_types.h:54
constexpr dicom_tag status
Status.
@ move
C-MOVE move request/response.
kcenon::common::Result< T > Result
Result type alias for operations returning a value.
Repository for prefetch rule and history persistence.
History record for a single prefetch operation.
std::string source_node_id
Source node ID.
std::chrono::system_clock::time_point prefetched_at
Timestamp.
std::string job_id
Associated job ID.
std::string study_uid
Study Instance UID.
std::string status
Status (pending, completed, failed)
std::string rule_id
Rule that triggered this (if any)
Rule defining when and how to prefetch DICOM data.
std::string name
Human-readable name.
std::string station_ae_filter
Station AE title filter.
std::string modality_filter
Modality filter (e.g., "CT,MR")
std::vector< std::string > prior_modalities
Modalities to fetch (empty = same)
size_t triggered_count
Times rule was triggered.
size_t studies_prefetched
Total studies prefetched.
int64_t pk
Primary key (0 if not persisted)
std::chrono::hours prior_lookback
Lookback period (default: 1 year)
std::string body_part_filter
Body part filter (e.g., "CHEST,ABDOMEN")
std::chrono::system_clock::time_point last_triggered
Last trigger time.
std::string schedule_cron
Cron expression (e.g., "0 6 * * *")
std::chrono::minutes advance_time
Prefetch N minutes before scheduled.
size_t max_prior_studies
Maximum prior studies to fetch.
prefetch_trigger trigger
What triggers this rule.
std::vector< std::string > source_node_ids
Nodes to search for data.
std::string rule_id
Unique rule identifier (UUID)
bool enabled
Whether the rule is active.
Query options for listing prefetch rules.