# HG changeset patch # User Sebastien Jodogne # Date 1680592572 -7200 # Node ID 988dab8deb1c155f5d6a8ed42441f36289070682 # Parent 49e906a8fea2274ad77628eacb87565882d3b687 "/tools/find" accepts the "WithLabels" and "WithoutLabels" arguments diff -r 49e906a8fea2 -r 988dab8deb1c NEWS --- a/NEWS Tue Apr 04 07:09:22 2023 +0200 +++ b/NEWS Tue Apr 04 09:16:12 2023 +0200 @@ -11,6 +11,7 @@ * API version upgraded to 20 * New URIs "/.../{id}/labels/{label}" to test/set/remove labels +* "/tools/find" accepts the "WithLabels" and "WithoutLabels" arguments * The "/patients/{id}", "/studies/{id}", "/series/{id}" and "/instances/{id}" contain the "Labels" field * "/system": added "UserMetadata" diff -r 49e906a8fea2 -r 988dab8deb1c OrthancServer/Sources/Database/InstallLabelsTable.sql --- a/OrthancServer/Sources/Database/InstallLabelsTable.sql Tue Apr 04 07:09:22 2023 +0200 +++ b/OrthancServer/Sources/Database/InstallLabelsTable.sql Tue Apr 04 09:16:12 2023 +0200 @@ -20,5 +20,9 @@ CREATE TABLE Labels( internalId INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE, - label TEXT + label TEXT NOT NULL, + PRIMARY KEY(internalId, label) -- Prevents duplicates ); + +CREATE INDEX LabelsIndex1 ON Labels(internalId); +CREATE INDEX LabelsIndex2 ON Labels(label); -- This index allows efficient lookups diff -r 49e906a8fea2 -r 988dab8deb1c OrthancServer/Sources/Database/PrepareDatabase.sql --- a/OrthancServer/Sources/Database/PrepareDatabase.sql Tue Apr 04 07:09:22 2023 +0200 +++ b/OrthancServer/Sources/Database/PrepareDatabase.sql Tue Apr 04 09:16:12 2023 +0200 @@ -94,7 +94,8 @@ -- New in Orthanc 1.12.0 CREATE TABLE Labels( internalId INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE, - label TEXT + label TEXT NOT NULL, + PRIMARY KEY(internalId, label) -- Prevents duplicates ); CREATE INDEX ChildrenIndex ON Resources(parentId); @@ -114,6 +115,10 @@ CREATE INDEX ChangesIndex ON Changes(internalId); +-- New in Orthanc 1.12.0 +CREATE INDEX LabelsIndex1 ON Labels(internalId); +CREATE INDEX LabelsIndex2 ON Labels(label); -- This index allows efficient lookups + CREATE TRIGGER AttachedFileDeleted AFTER DELETE ON AttachedFiles BEGIN diff -r 49e906a8fea2 -r 988dab8deb1c OrthancServer/Sources/Database/SQLiteDatabaseWrapper.cpp --- a/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.cpp Tue Apr 04 07:09:22 2023 +0200 +++ b/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.cpp Tue Apr 04 09:16:12 2023 +0200 @@ -345,16 +345,10 @@ const std::set& withoutLabels, uint32_t limit) ORTHANC_OVERRIDE { - if (!withLabels.empty() || - !withoutLabels.empty()) - { - throw OrthancException(ErrorCode_NotImplemented); - } - LookupFormatter formatter; std::string sql; - LookupFormatter::Apply(sql, formatter, lookup, queryLevel, limit); + LookupFormatter::Apply(sql, formatter, lookup, queryLevel, withLabels, withoutLabels, limit); sql = "CREATE TEMPORARY TABLE Lookup AS " + sql; @@ -1090,7 +1084,7 @@ } else { - SQLite::Statement s(db_, SQLITE_FROM_HERE, "INSERT INTO Labels (internalId, label) VALUES(?, ?)"); + SQLite::Statement s(db_, SQLITE_FROM_HERE, "INSERT OR IGNORE INTO Labels (internalId, label) VALUES(?, ?)"); s.BindInt64(0, resource); s.BindString(1, label); s.Run(); diff -r 49e906a8fea2 -r 988dab8deb1c OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp --- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp Tue Apr 04 07:09:22 2023 +0200 +++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp Tue Apr 04 09:16:12 2023 +0200 @@ -3072,6 +3072,8 @@ static const char* const KEY_QUERY = "Query"; static const char* const KEY_REQUESTED_TAGS = "RequestedTags"; static const char* const KEY_SINCE = "Since"; + static const char* const KEY_WITH_LABELS = "WithLabels"; // New in Orthanc 1.12.0 + static const char* const KEY_WITHOUT_LABELS = "WithoutLabels"; // New in Orthanc 1.12.0 if (call.IsDocumentation()) { @@ -3101,6 +3103,10 @@ "all Main Dicom Tags to keep backward compatibility with Orthanc prior to 1.11.0.", false) .SetRequestField(KEY_QUERY, RestApiCallDocumentation::Type_JsonObject, "Associative array containing the filter on the values of the DICOM tags", true) + .SetRequestField(KEY_WITH_LABELS, RestApiCallDocumentation::Type_JsonListOfStrings, + "List of strings specifying which labels must be present in the resources (new in Orthanc 1.12.0)", true) + .SetRequestField(KEY_WITHOUT_LABELS, RestApiCallDocumentation::Type_JsonListOfStrings, + "List of strings specifying which labels must not be present in the resources (new in Orthanc 1.12.0)", true) .AddAnswerType(MimeType_Json, "JSON array containing either the Orthanc identifiers, or detailed information " "about the reported resources (if `Expand` argument is `true`)"); return; @@ -3131,25 +3137,37 @@ request[KEY_CASE_SENSITIVE].type() != Json::booleanValue) { throw OrthancException(ErrorCode_BadRequest, - "Field \"" + std::string(KEY_CASE_SENSITIVE) + "\" should be a Boolean"); + "Field \"" + std::string(KEY_CASE_SENSITIVE) + "\" must be a Boolean"); } else if (request.isMember(KEY_LIMIT) && request[KEY_LIMIT].type() != Json::intValue) { throw OrthancException(ErrorCode_BadRequest, - "Field \"" + std::string(KEY_LIMIT) + "\" should be an integer"); + "Field \"" + std::string(KEY_LIMIT) + "\" must be an integer"); } else if (request.isMember(KEY_SINCE) && request[KEY_SINCE].type() != Json::intValue) { throw OrthancException(ErrorCode_BadRequest, - "Field \"" + std::string(KEY_SINCE) + "\" should be an integer"); + "Field \"" + std::string(KEY_SINCE) + "\" must be an integer"); } else if (request.isMember(KEY_REQUESTED_TAGS) && request[KEY_REQUESTED_TAGS].type() != Json::arrayValue) { throw OrthancException(ErrorCode_BadRequest, - "Field \"" + std::string(KEY_REQUESTED_TAGS) + "\" should be an array"); + "Field \"" + std::string(KEY_REQUESTED_TAGS) + "\" must be an array"); + } + else if (request.isMember(KEY_WITH_LABELS) && + request[KEY_WITH_LABELS].type() != Json::arrayValue) + { + throw OrthancException(ErrorCode_BadRequest, + "Field \"" + std::string(KEY_WITH_LABELS) + "\" must be an array of strings"); + } + else if (request.isMember(KEY_WITHOUT_LABELS) && + request[KEY_WITHOUT_LABELS].type() != Json::arrayValue) + { + throw OrthancException(ErrorCode_BadRequest, + "Field \"" + std::string(KEY_WITHOUT_LABELS) + "\" must be an array of strings"); } else { @@ -3172,7 +3190,7 @@ if (tmp < 0) { throw OrthancException(ErrorCode_ParameterOutOfRange, - "Field \"" + std::string(KEY_LIMIT) + "\" should be a positive integer"); + "Field \"" + std::string(KEY_LIMIT) + "\" must be a positive integer"); } limit = static_cast(tmp); @@ -3185,7 +3203,7 @@ if (tmp < 0) { throw OrthancException(ErrorCode_ParameterOutOfRange, - "Field \"" + std::string(KEY_SINCE) + "\" should be a positive integer"); + "Field \"" + std::string(KEY_SINCE) + "\" must be a positive integer"); } since = static_cast(tmp); @@ -3208,7 +3226,7 @@ if (request[KEY_QUERY][members[i]].type() != Json::stringValue) { throw OrthancException(ErrorCode_BadRequest, - "Tag \"" + members[i] + "\" should be associated with a string"); + "Tag \"" + members[i] + "\" must be associated with a string"); } const std::string value = request[KEY_QUERY][members[i]].asString(); @@ -3223,6 +3241,36 @@ } } + if (request.isMember(KEY_WITH_LABELS)) // New in Orthanc 1.12.0 + { + for (Json::Value::ArrayIndex i = 0; i < request[KEY_WITH_LABELS].size(); i++) + { + if (request[KEY_WITH_LABELS][i].type() != Json::stringValue) + { + throw OrthancException(ErrorCode_BadRequest, "Field \""+ std::string(KEY_WITH_LABELS) + "\" must contain strings"); + } + else + { + query.AddWithLabel(request[KEY_WITH_LABELS][i].asString()); + } + } + } + + if (request.isMember(KEY_WITHOUT_LABELS)) // New in Orthanc 1.12.0 + { + for (Json::Value::ArrayIndex i = 0; i < request[KEY_WITHOUT_LABELS].size(); i++) + { + if (request[KEY_WITHOUT_LABELS][i].type() != Json::stringValue) + { + throw OrthancException(ErrorCode_BadRequest, "Field \""+ std::string(KEY_WITHOUT_LABELS) + "\" must contain strings"); + } + else + { + query.AddWithoutLabel(request[KEY_WITHOUT_LABELS][i].asString()); + } + } + } + FindVisitor visitor(OrthancRestApi::GetDicomFormat(request, DicomToJsonFormat_Human), context.GetFindStorageAccessMode()); context.Apply(visitor, query, level, since, limit); visitor.Answer(call.GetOutput(), context, level, expand, requestedTags); diff -r 49e906a8fea2 -r 988dab8deb1c OrthancServer/Sources/Search/ISqlLookupFormatter.cpp --- a/OrthancServer/Sources/Search/ISqlLookupFormatter.cpp Tue Apr 04 07:09:22 2023 +0200 +++ b/OrthancServer/Sources/Search/ISqlLookupFormatter.cpp Tue Apr 04 09:16:12 2023 +0200 @@ -39,6 +39,7 @@ #include "DatabaseConstraint.h" #include +#include namespace Orthanc @@ -268,12 +269,46 @@ " AND " + tag + ".tagElement = " + boost::lexical_cast(constraint.GetTag().GetElement())); } + + + static std::string Join(const std::list& values, + const std::string& prefix, + const std::string& separator) + { + if (values.empty()) + { + return ""; + } + else + { + std::string s = prefix; + + bool first = true; + for (std::list::const_iterator it = values.begin(); it != values.end(); ++it) + { + if (first) + { + first = false; + } + else + { + s += separator; + } + + s += *it; + } + + return s; + } + } void ISqlLookupFormatter::Apply(std::string& sql, ISqlLookupFormatter& formatter, const std::vector& lookup, ResourceType queryLevel, + const std::set& withLabels, + const std::set& withoutLabels, size_t limit) { assert(ResourceType_Patient < ResourceType_Study && @@ -346,9 +381,44 @@ FormatLevel(static_cast(level - 1)) + ".internalId=" + FormatLevel(static_cast(level)) + ".parentId"); } - - sql += (joins + " WHERE " + FormatLevel(queryLevel) + ".resourceType = " + - formatter.FormatResourceType(queryLevel) + comparisons); + + std::list where; + + if (!withLabels.empty()) + { + std::list labels; + for (std::set::const_iterator it = withLabels.begin(); it != withLabels.end(); ++it) + { + labels.push_back(formatter.GenerateParameter(*it)); + } + + where.push_back(boost::lexical_cast(withLabels.size()) + + " = (SELECT COUNT(1) FROM Labels WHERE internalId = " + FormatLevel(queryLevel) + + ".internalId AND label IN (" + Join(labels, "", ", ") + "))"); + } + + if (!withoutLabels.empty()) + { + /** + * "In SQL Server, NOT EXISTS and NOT IN predicates are the best + * way to search for missing values, as long as both columns in + * question are NOT NULL." + * https://explainextended.com/2009/09/15/not-in-vs-not-exists-vs-left-join-is-null-sql-server/ + **/ + std::list labels; + for (std::set::const_iterator it = withoutLabels.begin(); it != withoutLabels.end(); ++it) + { + labels.push_back(formatter.GenerateParameter(*it)); + } + + where.push_back("NOT EXISTS (SELECT 1 FROM Labels WHERE internalId = " + FormatLevel(queryLevel) + + ".internalId AND label IN (" + Join(labels, "", ", ") + "))"); + } + + where.push_back(FormatLevel(queryLevel) + ".resourceType = " + + formatter.FormatResourceType(queryLevel) + comparisons); + + sql += joins + Join(where, " WHERE ", " AND "); if (limit != 0) { diff -r 49e906a8fea2 -r 988dab8deb1c OrthancServer/Sources/Search/ISqlLookupFormatter.h --- a/OrthancServer/Sources/Search/ISqlLookupFormatter.h Tue Apr 04 07:09:22 2023 +0200 +++ b/OrthancServer/Sources/Search/ISqlLookupFormatter.h Tue Apr 04 09:16:12 2023 +0200 @@ -60,6 +60,8 @@ ISqlLookupFormatter& formatter, const std::vector& lookup, ResourceType queryLevel, + const std::set& withLabels, // New in Orthanc 1.12.0 + const std::set& withoutLabels, // New in Orthanc 1.12.0 size_t limit); }; }