changeset 5229:365eb5ea5492 db-protobuf

integration mainline->db-protobuf
author Sebastien Jodogne <s.jodogne@gmail.com>
date Tue, 04 Apr 2023 09:46:40 +0200
parents 988dab8deb1c (diff) 993a6b23f032 (current diff)
children 887df8c45838
files NEWS OrthancFramework/Resources/Patches/openssl-3.0.5.patch
diffstat 26 files changed, 878 insertions(+), 133 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Tue Apr 04 09:46:09 2023 +0200
+++ b/NEWS	Tue Apr 04 09:46:40 2023 +0200
@@ -1,11 +1,20 @@
 Pending changes in the mainline
 ===============================
 
+General
+-------
+
+* Support for labels associated with patients, studies, series, and instances
+
 REST API
 --------
 
 * API version upgraded to 20
-* /system: added "UserMetadata"
+* 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"
 
 Plugins
 -------
@@ -18,7 +27,7 @@
 
 * Enforce the existence of the patient/study/instance while creating its archive
 * Security: New configuration option "RestApiWriteToFileSystemEnabled"
-  to allow "/instances/../export" that is now disabled by default
+  to allow "/instances/../export" (the latter is now disabled by default)
 * Fix issue 214: VOILUTSequence is not returned in Wado-RS
 * Fix /tools/reset crashing when ExtraMainDicomTags were defined
 * Fix Housekeeper plugin infinite loop if Orthanc is empty.
@@ -39,7 +48,7 @@
   AcceptedTransferSyntaxes.
 * Made the default SQLite DB more robust wrt future updates like
   adding new columns in DB.
-* Made the HTTP Client errors more verbose by including the url in the logs.
+* Made the HTTP Client errors more verbose by including the URL in the logs.
 * Optimization: now using multiple threads to transcode files for
   asynchronous download of studies archive.
 * New configuration "KeepAliveTimeout" with a default value of 1 second.
@@ -150,7 +159,7 @@
 * Housekeeper plugin: Fix resume of previous processing
 * Added missing MOVEPatientRootQueryRetrieveInformationModel in 
   DicomControlUserConnection::SetupPresentationContexts()
-* Improved HttpClient error logging (add method + url)
+* Improved HttpClient error logging (add method + URL)
 
 REST API
 --------
--- a/OrthancServer/CMakeLists.txt	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/CMakeLists.txt	Tue Apr 04 09:46:40 2023 +0200
@@ -228,16 +228,15 @@
 #####################################################################
 
 set(ORTHANC_EMBEDDED_FILES
-  CONFIGURATION_SAMPLE         ${CMAKE_SOURCE_DIR}/Resources/Configuration.json
-  DICOM_CONFORMANCE_STATEMENT  ${CMAKE_SOURCE_DIR}/Resources/DicomConformanceStatement.txt
-  FONT_UBUNTU_MONO_BOLD_16     ${CMAKE_SOURCE_DIR}/Resources/Fonts/UbuntuMonoBold-16.json
-  LUA_TOOLBOX                  ${CMAKE_SOURCE_DIR}/Resources/Toolbox.lua
-  PREPARE_DATABASE             ${CMAKE_SOURCE_DIR}/Sources/Database/PrepareDatabase.sql
-  UPGRADE_DATABASE_3_TO_4      ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade3To4.sql
-  UPGRADE_DATABASE_4_TO_5      ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade4To5.sql
-
-  INSTALL_TRACK_ATTACHMENTS_SIZE
-  ${CMAKE_SOURCE_DIR}/Sources/Database/InstallTrackAttachmentsSize.sql
+  CONFIGURATION_SAMPLE            ${CMAKE_SOURCE_DIR}/Resources/Configuration.json
+  DICOM_CONFORMANCE_STATEMENT     ${CMAKE_SOURCE_DIR}/Resources/DicomConformanceStatement.txt
+  FONT_UBUNTU_MONO_BOLD_16        ${CMAKE_SOURCE_DIR}/Resources/Fonts/UbuntuMonoBold-16.json
+  LUA_TOOLBOX                     ${CMAKE_SOURCE_DIR}/Resources/Toolbox.lua
+  PREPARE_DATABASE                ${CMAKE_SOURCE_DIR}/Sources/Database/PrepareDatabase.sql
+  UPGRADE_DATABASE_3_TO_4         ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade3To4.sql
+  UPGRADE_DATABASE_4_TO_5         ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade4To5.sql
+  INSTALL_TRACK_ATTACHMENTS_SIZE  ${CMAKE_SOURCE_DIR}/Sources/Database/InstallTrackAttachmentsSize.sql
+  INSTALL_LABELS_TABLE            ${CMAKE_SOURCE_DIR}/Sources/Database/InstallLabelsTable.sql
   )
 
 if (STANDALONE_BUILD)
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabase.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabase.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -565,8 +565,16 @@
                                       std::list<std::string>* instancesId,
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& withLabels,
+                                      const std::set<std::string>& withoutLabels,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
+      if (!withLabels.empty() ||
+          !withoutLabels.empty())
+      {
+        throw OrthancException(ErrorCode_InternalError);  // "HasLabelsSupport()" has returned "false"
+      }
+      
       if (that_.extensions_.lookupResources == NULL)
       {
         // Fallback to compatibility mode
@@ -1413,6 +1421,27 @@
         CheckSuccess(that_.extensions_.tagMostRecentPatient(that_.payload_, patient));
       }
     }
+
+
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
   };
 
 
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabase.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabase.h	Tue Apr 04 09:46:40 2023 +0200
@@ -113,6 +113,11 @@
       return false;  // No support for revisions in old API
     }
 
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE
+    {
+      return false;
+    }
+
     void AnswerReceived(const _OrthancPluginDatabaseAnswer& answer);
   };
 }
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -800,8 +800,16 @@
                                       std::list<std::string>* instancesId, // Can be NULL if not needed
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& withLabels,
+                                      const std::set<std::string>& withoutLabels,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
+      if (!withLabels.empty() ||
+          !withoutLabels.empty())
+      {
+        throw OrthancException(ErrorCode_InternalError);  // "HasLabelsSupport()" has returned "false"
+      }
+      
       std::vector<OrthancPluginDatabaseConstraint> constraints;
       std::vector< std::vector<const char*> > constraintsValues;
 
@@ -1027,6 +1035,27 @@
         return false;
       }
     }
+
+
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
   };
 
   
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.h	Tue Apr 04 09:46:40 2023 +0200
@@ -82,6 +82,11 @@
                          IStorageArea& storageArea) ORTHANC_OVERRIDE;    
 
     virtual bool HasRevisionsSupport() const ORTHANC_OVERRIDE;
+
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE
+    {
+      return false;
+    }
   };
 }
 
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -915,8 +915,17 @@
                                       std::list<std::string>* instancesId, // Can be NULL if not needed
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& withLabels,
+                                      const std::set<std::string>& withoutLabels,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
+      if (!database_.HasLabelsSupport() &&
+          (!withLabels.empty() ||
+           !withoutLabels.empty()))
+      {
+        throw OrthancException(ErrorCode_InternalError);
+      }
+
       DatabasePluginMessages::TransactionRequest request;
       request.mutable_lookup_resources()->set_query_level(Convert(queryLevel));
       request.mutable_lookup_resources()->set_limit(limit);
@@ -966,6 +975,12 @@
             throw OrthancException(ErrorCode_ParameterOutOfRange);
         }
       }
+
+      if (!withLabels.empty() ||
+          !withoutLabels.empty())
+      {
+        throw OrthancException(ErrorCode_NotImplemented);  // TODO
+      }
       
       DatabasePluginMessages::TransactionResponse response;
       ExecuteTransaction(response, DatabasePluginMessages::OPERATION_LOOKUP_RESOURCES, request);
@@ -1132,6 +1147,27 @@
         return false;
       }
     }
+
+    
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_NotImplemented);
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_NotImplemented);
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_NotImplemented);
+    }
   };
 
 
@@ -1146,7 +1182,8 @@
     open_(false),
     databaseVersion_(0),
     hasFlushToDisk_(false),
-    hasRevisionsSupport_(false)
+    hasRevisionsSupport_(false),
+    hasLabelsSupport_(false)
   {
     CLOG(INFO, PLUGINS) << "Identifier of this Orthanc server for the global properties "
                         << "of the custom database: \"" << serverIdentifier << "\"";
@@ -1186,6 +1223,7 @@
       databaseVersion_ = response.get_system_information().database_version();
       hasFlushToDisk_ = response.get_system_information().supports_flush_to_disk();
       hasRevisionsSupport_ = response.get_system_information().supports_revisions();
+      hasLabelsSupport_ = response.get_system_information().supports_labels();
     }
 
     open_ = true;    
@@ -1307,4 +1345,17 @@
       return hasRevisionsSupport_;
     }
   }
+
+  
+  bool OrthancPluginDatabaseV4::HasLabelsSupport() const
+  {
+    if (!open_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      return hasLabelsSupport_;
+    }
+  }
 }
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.h	Tue Apr 04 09:46:40 2023 +0200
@@ -44,6 +44,7 @@
     unsigned int                            databaseVersion_;
     bool                                    hasFlushToDisk_;
     bool                                    hasRevisionsSupport_;
+    bool                                    hasLabelsSupport_;
 
     void CheckSuccess(OrthancPluginErrorCode code) const;
 
@@ -93,6 +94,8 @@
                          IStorageArea& storageArea) ORTHANC_OVERRIDE;    
 
     virtual bool HasRevisionsSupport() const ORTHANC_OVERRIDE;
+
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE;
   };
 }
 
--- a/OrthancServer/Plugins/Include/orthanc/OrthancDatabasePlugin.proto	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Plugins/Include/orthanc/OrthancDatabasePlugin.proto	Tue Apr 04 09:46:40 2023 +0200
@@ -129,6 +129,7 @@
     uint32 database_version = 1;
     bool supports_flush_to_disk = 2;
     bool supports_revisions = 3;
+    bool supports_labels = 4;
   }
 }
 
--- a/OrthancServer/Sources/Database/IDatabaseWrapper.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Database/IDatabaseWrapper.h	Tue Apr 04 09:46:40 2023 +0200
@@ -200,6 +200,8 @@
                                         std::list<std::string>* instancesId, // Can be NULL if not needed
                                         const std::vector<DatabaseConstraint>& lookup,
                                         ResourceType queryLevel,
+                                        const std::set<std::string>& withLabels,
+                                        const std::set<std::string>& withoutLabels,
                                         uint32_t limit) = 0;
 
       // Returns "true" iff. the instance is new and has been inserted
@@ -236,6 +238,20 @@
                                            ResourceType& type,
                                            std::string& parentPublicId,
                                            const std::string& publicId) = 0;
+
+
+      /**
+       * Primitives introduced in Orthanc 1.12.0
+       **/
+
+      virtual void AddLabel(int64_t resource,
+                            const std::string& label) = 0;
+
+      virtual void RemoveLabel(int64_t resource,
+                               const std::string& label) = 0;
+
+      virtual void ListLabels(std::set<std::string>& target,
+                              int64_t resource) = 0;
     };
 
 
@@ -260,5 +276,7 @@
                          IStorageArea& storageArea) = 0;
 
     virtual bool HasRevisionsSupport() const = 0;
+
+    virtual bool HasLabelsSupport() const = 0;
   };
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/Database/InstallLabelsTable.sql	Tue Apr 04 09:46:40 2023 +0200
@@ -0,0 +1,28 @@
+-- Orthanc - A Lightweight, RESTful DICOM Store
+-- Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+-- Department, University Hospital of Liege, Belgium
+-- Copyright (C) 2017-2023 Osimis S.A., Belgium
+-- Copyright (C) 2021-2023 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+--
+-- This program is free software: you can redistribute it and/or
+-- modify it under the terms of the GNU General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+-- General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+CREATE TABLE Labels(
+       internalId INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE,
+       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
--- a/OrthancServer/Sources/Database/PrepareDatabase.sql	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Database/PrepareDatabase.sql	Tue Apr 04 09:46:40 2023 +0200
@@ -91,6 +91,13 @@
        patientId INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE
        );
 
+-- New in Orthanc 1.12.0
+CREATE TABLE Labels(
+       internalId INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE,
+       label TEXT NOT NULL,
+       PRIMARY KEY(internalId, label)  -- Prevents duplicates
+       );
+
 CREATE INDEX ChildrenIndex ON Resources(parentId);
 CREATE INDEX PublicIndex ON Resources(publicId);
 CREATE INDEX ResourceTypeIndex ON Resources(resourceType);
@@ -108,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
--- a/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -341,12 +341,14 @@
                                       std::list<std::string>* instancesId,
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& withLabels,
+                                      const std::set<std::string>& withoutLabels,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
       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;
     
@@ -1071,6 +1073,56 @@
         s.Run();
       }
     }
+
+
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      if (label.empty())
+      {
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+      else
+      {
+        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();
+      }
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      if (label.empty())
+      {
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+      else
+      {
+        SQLite::Statement s(db_, SQLITE_FROM_HERE, "DELETE FROM Labels WHERE internalId=? AND label=?");
+        s.BindInt64(0, resource);
+        s.BindString(1, label);
+        s.Run();
+      }
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      target.clear();
+
+      SQLite::Statement s(db_, SQLITE_FROM_HERE, 
+                          "SELECT label FROM Labels WHERE internalId=?");
+      s.BindInt64(0, resource);
+
+      while (s.Step())
+      {
+        target.insert(s.ColumnString(0));
+      }
+    }
   };
 
 
@@ -1344,9 +1396,9 @@
                                "Incompatible version of the Orthanc database: " + tmp);
       }
 
-      // New in Orthanc 1.5.1
       if (version_ == 6)
       {
+        // New in Orthanc 1.5.1
         if (!transaction->LookupGlobalProperty(tmp, GlobalProperty_GetTotalSizeIsFast, true /* unused in SQLite */) ||
             tmp != "1")
         {
@@ -1355,6 +1407,15 @@
           ServerResources::GetFileResource(query, ServerResources::INSTALL_TRACK_ATTACHMENTS_SIZE);
           db_.Execute(query);
         }
+
+        // New in Orthanc 1.12.0
+        if (!db_.DoesTableExist("Labels"))
+        {
+          LOG(INFO) << "Installing the \"Labels\" table";
+          std::string query;
+          ServerResources::GetFileResource(query, ServerResources::INSTALL_LABELS_TABLE);
+          db_.Execute(query);
+        }
       }
 
       transaction->Commit(0);
--- a/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.h	Tue Apr 04 09:46:40 2023 +0200
@@ -97,6 +97,11 @@
       return false;  // TODO - REVISIONS
     }
 
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE
+    {
+      return true;
+    }
+
 
     /**
      * The "StartTransaction()" method is guaranteed to return a class
--- a/OrthancServer/Sources/Database/StatelessDatabaseOperations.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Database/StatelessDatabaseOperations.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -715,10 +715,10 @@
                                                    const std::string& publicId,
                                                    ResourceType level,
                                                    const std::set<DicomTag>& requestedTags,
-                                                   ExpandResourceDbFlags expandFlags)
+                                                   ExpandResourceFlags expandFlags)
   {    
     class Operations : public ReadOnlyOperationsT6<
-      bool&, ExpandedResource&, const std::string&, ResourceType, const std::set<DicomTag>&, ExpandResourceDbFlags>
+      bool&, ExpandedResource&, const std::string&, ResourceType, const std::set<DicomTag>&, ExpandResourceFlags>
     {
     private:
   
@@ -778,7 +778,7 @@
         else
         {
           ExpandedResource& target = tuple.get<1>();
-          ExpandResourceDbFlags expandFlags = tuple.get<5>();
+          ExpandResourceFlags expandFlags = tuple.get<5>();
 
           // Set information about the parent resource (if it exists)
           if (type == ResourceType_Patient)
@@ -798,16 +798,15 @@
             target.parentId_ = parent;
           }
 
-          target.type_ = type;
-          target.id_ = tuple.get<2>();
-
-          if (expandFlags & ExpandResourceDbFlags_IncludeChildren)
+          target.SetResource(type, tuple.get<2>());
+
+          if (expandFlags & ExpandResourceFlags_IncludeChildren)
           {
             // List the children resources
             transaction.GetChildrenPublicId(target.childrenIds_, internalId);
           }
 
-          if (expandFlags & ExpandResourceDbFlags_IncludeMetadata)
+          if (expandFlags & ExpandResourceFlags_IncludeMetadata)
           {
             // Extract the metadata
             transaction.GetAllMetadata(target.metadata_, internalId);
@@ -869,10 +868,10 @@
             LookupStringMetadata(target.mainDicomTagsSignature_, target.metadata_, MetadataType_MainDicomTagsSignature);
           }
 
-          if (expandFlags & ExpandResourceDbFlags_IncludeMainDicomTags)
+          if (expandFlags & ExpandResourceFlags_IncludeMainDicomTags)
           {
             // read all tags from DB
-            transaction.GetMainDicomTags(target.tags_, internalId);
+            transaction.GetMainDicomTags(target.GetMainDicomTags(), internalId);
 
             // read all main sequences from DB
             std::string serializedSequences;
@@ -882,7 +881,7 @@
               Toolbox::ReadJson(jsonMetadata, serializedSequences);
 
               assert(jsonMetadata["Version"].asInt() == 1);
-              target.tags_.FromDicomAsJson(jsonMetadata["Sequences"], true /* append */, true /* parseSequences */);
+              target.GetMainDicomTags().FromDicomAsJson(jsonMetadata["Sequences"], true /* append */, true /* parseSequences */);
             }
 
             // check if we have access to all requestedTags or if we must get tags from parents
@@ -895,7 +894,7 @@
               FromDcmtkBridge::ParseListOfTags(savedMainDicomTags, target.mainDicomTagsSignature_);
 
               // read parent main dicom tags as long as we have not gathered all requested tags
-              ResourceType currentLevel = target.type_;
+              ResourceType currentLevel = target.GetLevel();
               int64_t currentInternalId = internalId;
               Toolbox::GetMissingsFromSet(target.missingRequestedTags_, requestedTags, savedMainDicomTags);
 
@@ -931,7 +930,7 @@
                   DicomMap parentTags;
                   transaction.GetMainDicomTags(parentTags, currentParentId);
 
-                  target.tags_.Merge(parentTags);
+                  target.GetMainDicomTags().Merge(parentTags);
                 }
 
                 currentInternalId = currentParentId;
@@ -939,6 +938,11 @@
             }
           }
 
+          if (expandFlags & ExpandResourceFlags_IncludeLabels)
+          {
+            transaction.ListLabels(target.labels_, internalId);
+          }
+
           std::string tmp;
 
           if (LookupStringMetadata(tmp, target.metadata_, MetadataType_AnonymizedFrom))
@@ -1066,7 +1070,7 @@
   void StatelessDatabaseOperations::GetAllUuids(std::list<std::string>& target,
                                                 ResourceType resourceType,
                                                 size_t since,
-                                                size_t limit)
+                                                uint32_t limit)
   {
     if (limit == 0)
     {
@@ -1646,7 +1650,9 @@
       {
         // TODO - CANDIDATE FOR "TransactionType_Implicit"
         std::list<std::string> tmp;
-        transaction.ApplyLookupResources(tmp, NULL, query_, level_, 0);
+        std::set<std::string> withLabels;
+        std::set<std::string> withoutLabels;
+        transaction.ApplyLookupResources(tmp, NULL, query_, level_, withLabels, withoutLabels, 0);
         CopyListToVector(result_, tmp);
       }
     };
@@ -1915,9 +1921,12 @@
                                                          std::vector<std::string>* instancesId,
                                                          const DatabaseLookup& lookup,
                                                          ResourceType queryLevel,
-                                                         size_t limit)
+                                                         const std::set<std::string>& withLabels,
+                                                         const std::set<std::string>& withoutLabels,
+                                                         uint32_t limit)
   {
-    class Operations : public ReadOnlyOperationsT4<bool, const std::vector<DatabaseConstraint>&, ResourceType, size_t>
+    class Operations : public ReadOnlyOperationsT6<bool, const std::vector<DatabaseConstraint>&, ResourceType,
+                                                   const std::set<std::string>&, const std::set<std::string>&, size_t>
     {
     private:
       std::list<std::string>  resourcesList_;
@@ -1940,11 +1949,13 @@
         // TODO - CANDIDATE FOR "TransactionType_Implicit"
         if (tuple.get<0>())
         {
-          transaction.ApplyLookupResources(resourcesList_, &instancesList_, tuple.get<1>(), tuple.get<2>(), tuple.get<3>());
+          transaction.ApplyLookupResources(
+            resourcesList_, &instancesList_, tuple.get<1>(), tuple.get<2>(), tuple.get<3>(), tuple.get<4>(), tuple.get<5>());
         }
         else
         {
-          transaction.ApplyLookupResources(resourcesList_, NULL, tuple.get<1>(), tuple.get<2>(), tuple.get<3>());
+          transaction.ApplyLookupResources(
+            resourcesList_, NULL, tuple.get<1>(), tuple.get<2>(), tuple.get<3>(), tuple.get<4>(), tuple.get<5>());
         }
       }
     };
@@ -1954,7 +1965,7 @@
     NormalizeLookup(normalized, lookup, queryLevel);
 
     Operations operations;
-    operations.Apply(*this, (instancesId != NULL), normalized, queryLevel, limit);
+    operations.Apply(*this, (instancesId != NULL), normalized, queryLevel, withLabels, withoutLabels, limit);
     
     CopyListToVector(resourcesId, operations.GetResourcesList());
 
@@ -3530,4 +3541,96 @@
     Apply(operations);
     return operations.GetStatus();
   }
+
+
+  void StatelessDatabaseOperations::ListLabels(std::set<std::string>& target,
+                                               const std::string& publicId,
+                                               ResourceType level)
+  {
+    class Operations : public ReadOnlyOperationsT3<std::set<std::string>&, const std::string&, ResourceType>
+    {
+    public:
+      virtual void ApplyTuple(ReadOnlyTransaction& transaction,
+                              const Tuple& tuple) ORTHANC_OVERRIDE
+      {
+        ResourceType type;
+        int64_t id;
+        if (!transaction.LookupResource(id, type, tuple.get<1>()) ||
+            tuple.get<2>() != type)
+        {
+          throw OrthancException(ErrorCode_UnknownResource);
+        }
+        else
+        {
+          transaction.ListLabels(tuple.get<0>(), id);
+        }
+      }
+    };
+
+    Operations operations;
+    operations.Apply(*this, target, publicId, level);
+  }
+
+
+  void StatelessDatabaseOperations::ModifyLabel(const std::string& publicId,
+                                                ResourceType level,
+                                                const std::string& label,
+                                                LabelOperation operation)
+  {
+    class Operations : public IReadWriteOperations
+    {
+    private:
+      const std::string& publicId_;
+      ResourceType       level_;
+      const std::string& label_;
+      LabelOperation     operation_;
+
+    public:
+      Operations(const std::string& publicId,
+                 ResourceType level,
+                 const std::string& label,
+                 LabelOperation operation) :
+        publicId_(publicId),
+        level_(level),
+        label_(label),
+        operation_(operation)
+      {
+      }
+
+      virtual void Apply(ReadWriteTransaction& transaction) ORTHANC_OVERRIDE
+      {
+        ResourceType type;
+        int64_t id;
+        if (!transaction.LookupResource(id, type, publicId_) ||
+            level_ != type)
+        {
+          throw OrthancException(ErrorCode_UnknownResource);
+        }
+        else
+        {
+          switch (operation_)
+          {
+            case LabelOperation_Add:
+              transaction.AddLabel(id, label_);
+              break;
+
+            case LabelOperation_Remove:
+              transaction.RemoveLabel(id, label_);
+              break;
+
+            default:
+              throw OrthancException(ErrorCode_ParameterOutOfRange);
+          }
+        }
+      }
+    };
+
+    if (!Toolbox::IsAsciiString(label))
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange, "A label must only contain ASCII characters");
+    }
+    
+    Operations operations(publicId, level, label, operation);
+    Apply(operations);
+  }
 }
--- a/OrthancServer/Sources/Database/StatelessDatabaseOperations.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Database/StatelessDatabaseOperations.h	Tue Apr 04 09:46:40 2023 +0200
@@ -37,15 +37,18 @@
   class ParsedDicomFile;
   struct ServerIndexChange;
 
-  struct ExpandedResource : public boost::noncopyable
+  class ExpandedResource : public boost::noncopyable
   {
+  private:
     std::string                         id_;
-    DicomMap                            tags_;          // all main tags and main sequences from DB
+    ResourceType                        level_;
+    DicomMap                            tags_;  // all main tags and main sequences from DB
+
+  public:
     std::string                         mainDicomTagsSignature_;
     std::string                         parentId_;
     std::list<std::string>              childrenIds_;
     std::map<MetadataType, std::string> metadata_;
-    ResourceType                        type_;
     std::string                         anonymizedFrom_;
     std::string                         modifiedFrom_;
     std::string                         lastUpdate_;
@@ -62,18 +65,51 @@
     size_t                              fileSize_;
     std::string                         fileUuid_;
     int                                 indexInSeries_;
+
+    // New in Orthanc 1.12.0
+    std::set<std::string>               labels_;
+
+  public:
+    void SetResource(ResourceType level,
+                     const std::string& id)
+    {
+      level_ = level;
+      id_ = id;
+    }
+
+    const std::string& GetPublicId() const
+    {
+      return id_;
+    }
+
+    ResourceType GetLevel() const
+    {
+      return level_;
+    }
+
+    DicomMap& GetMainDicomTags()
+    {
+      return tags_;
+    }
+
+    const DicomMap& GetMainDicomTags() const
+    {
+      return tags_;
+    }
   };
 
-  enum ExpandResourceDbFlags
+  enum ExpandResourceFlags
   {
-    ExpandResourceDbFlags_None                    = 0,
-    ExpandResourceDbFlags_IncludeMetadata         = (1 << 0),
-    ExpandResourceDbFlags_IncludeChildren         = (1 << 1),
-    ExpandResourceDbFlags_IncludeMainDicomTags    = (1 << 2),
+    ExpandResourceFlags_None                    = 0,
+    ExpandResourceFlags_IncludeMetadata         = (1 << 0),
+    ExpandResourceFlags_IncludeChildren         = (1 << 1),
+    ExpandResourceFlags_IncludeMainDicomTags    = (1 << 2),
+    ExpandResourceFlags_IncludeLabels           = (1 << 3),
 
-    ExpandResourceDbFlags_Default = (ExpandResourceDbFlags_IncludeMetadata |
-                                     ExpandResourceDbFlags_IncludeChildren |
-                                     ExpandResourceDbFlags_IncludeMainDicomTags)
+    ExpandResourceFlags_Default = (ExpandResourceFlags_IncludeMetadata |
+                                     ExpandResourceFlags_IncludeChildren |
+                                     ExpandResourceFlags_IncludeMainDicomTags |
+                                     ExpandResourceFlags_IncludeLabels)
   };
 
   class StatelessDatabaseOperations : public boost::noncopyable
@@ -82,6 +118,12 @@
     typedef std::list<FileInfo> Attachments;
     typedef std::map<std::pair<ResourceType, MetadataType>, std::string>  MetadataMap;
 
+    enum LabelOperation
+    {
+      LabelOperation_Add,
+      LabelOperation_Remove
+    };
+
     class ITransactionContext : public IDatabaseListener
     {
     public:
@@ -157,9 +199,11 @@
                                 std::list<std::string>* instancesId, // Can be NULL if not needed
                                 const std::vector<DatabaseConstraint>& lookup,
                                 ResourceType queryLevel,
-                                size_t limit)
+                                const std::set<std::string>& withLabels,
+                                const std::set<std::string>& withoutLabels,
+                                uint32_t limit)
       {
-        return transaction_.ApplyLookupResources(resourcesId, instancesId, lookup, queryLevel, limit);
+        return transaction_.ApplyLookupResources(resourcesId, instancesId, lookup, queryLevel, withLabels, withoutLabels, limit);
       }
 
       void GetAllMetadata(std::map<MetadataType, std::string>& target,
@@ -177,7 +221,7 @@
       void GetAllPublicIds(std::list<std::string>& target,
                            ResourceType resourceType,
                            size_t since,
-                           size_t limit)
+                           uint32_t limit)
       {
         return transaction_.GetAllPublicIds(target, resourceType, since, limit);
       }  
@@ -185,9 +229,9 @@
       void GetChanges(std::list<ServerIndexChange>& target /*out*/,
                       bool& done /*out*/,
                       int64_t since,
-                      uint32_t maxResults)
+                      uint32_t limit)
       {
-        transaction_.GetChanges(target, done, since, maxResults);
+        transaction_.GetChanges(target, done, since, limit);
       }
 
       void GetChildrenInternalId(std::list<int64_t>& target,
@@ -205,9 +249,9 @@
       void GetExportedResources(std::list<ExportedResource>& target /*out*/,
                                 bool& done /*out*/,
                                 int64_t since,
-                                uint32_t maxResults)
+                                uint32_t limit)
       {
-        return transaction_.GetExportedResources(target, done, since, maxResults);
+        return transaction_.GetExportedResources(target, done, since, limit);
       }
 
       void GetLastChange(std::list<ServerIndexChange>& target /*out*/)
@@ -310,6 +354,12 @@
       {
         return transaction_.LookupResourceAndParent(id, type, parentPublicId, publicId);
       }
+
+      void ListLabels(std::set<std::string>& target,
+                      int64_t id)
+      {
+        transaction_.ListLabels(target, id);
+      }
     };
 
 
@@ -422,6 +472,18 @@
                              unsigned int maximumPatients,
                              uint64_t addedInstanceSize,
                              const std::string& newPatientId);
+
+      void AddLabel(int64_t id,
+                    const std::string& label)
+      {
+        transaction_.AddLabel(id, label);
+      }
+
+      void RemoveLabel(int64_t id,
+                    const std::string& label)
+      {
+        transaction_.RemoveLabel(id, label);
+      }
     };
 
 
@@ -503,7 +565,7 @@
                         const std::string& publicId,
                         ResourceType level,
                         const std::set<DicomTag>& requestedTags,
-                        ExpandResourceDbFlags expandFlags);
+                        ExpandResourceFlags expandFlags);
 
     void GetAllMetadata(std::map<MetadataType, std::string>& target,
                         const std::string& publicId,
@@ -515,7 +577,7 @@
     void GetAllUuids(std::list<std::string>& target,
                      ResourceType resourceType,
                      size_t since,
-                     size_t limit);
+                     uint32_t limit);
 
     void GetGlobalStatistics(/* out */ uint64_t& diskSize,
                              /* out */ uint64_t& uncompressedSize,
@@ -531,13 +593,13 @@
 
     void GetChanges(Json::Value& target,
                     int64_t since,
-                    unsigned int maxResults);
+                    uint32_t limit);
 
     void GetLastChange(Json::Value& target);
 
     void GetExportedResources(Json::Value& target,
                               int64_t since,
-                              unsigned int maxResults);
+                              uint32_t limit);
 
     void GetLastExportedResource(Json::Value& target);
 
@@ -605,7 +667,9 @@
                               std::vector<std::string>* instancesId,  // Can be NULL if not needed
                               const DatabaseLookup& lookup,
                               ResourceType queryLevel,
-                              size_t limit);
+                              const std::set<std::string>& withLabels,
+                              const std::set<std::string>& withoutLabels,
+                              uint32_t limit);
 
     bool DeleteResource(Json::Value& remainingAncestor /* out */,
                         const std::string& uuid,
@@ -683,5 +747,14 @@
                               bool hasOldRevision,
                               int64_t oldRevision,
                               const std::string& oldMd5);
+
+    void ListLabels(std::set<std::string>& target,
+                    const std::string& publicId,
+                    ResourceType level);
+
+    void ModifyLabel(const std::string& publicId,
+                     ResourceType level,
+                     const std::string& label,
+                     LabelOperation operation);
   };
 }
--- a/OrthancServer/Sources/OrthancFindRequestHandler.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/OrthancFindRequestHandler.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -59,7 +59,8 @@
     requestedTags.erase(DICOM_TAG_QUERY_RETRIEVE_LEVEL); // this is not part of the answer
 
     // reuse ExpandResource to get missing tags and computed tags (ModalitiesInStudy ...).  This code is therefore shared between C-Find, tools/find, list-resources and QIDO-RS
-    context.ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson, level, requestedTags, ExpandResourceDbFlags_IncludeMainDicomTags, allowStorageAccess);
+    context.ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson,
+                           level, requestedTags, ExpandResourceFlags_IncludeMainDicomTags, allowStorageAccess);
 
     DicomMap result;
 
@@ -84,7 +85,7 @@
       else
       {
         const DicomTag& tag = query.GetElement(i).GetTag();
-        const DicomValue* value = resource.tags_.TestAndGetValue(tag);
+        const DicomValue* value = resource.GetMainDicomTags().TestAndGetValue(tag);
 
         if (value != NULL &&
             !value->IsNull() &&
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -1971,6 +1971,129 @@
 
 
 
+  // Handling of labels -------------------------------------------------------
+
+  static void ListLabels(RestApiGetCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("List labels (new in Orthanc 1.12.0)")
+        .SetDescription("Get the labels that are associated with the given " + r)
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .AddAnswerType(MimeType_Json, "JSON array containing the names of the labels")
+        .SetHttpGetSample(GetDocumentationSampleResource(t) + "/labels", true);
+      return;
+    }
+
+    assert(!call.GetFullUri().empty());
+    const std::string publicId = call.GetUriComponent("id", "");
+    ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::set<std::string> labels;
+    OrthancRestApi::GetIndex(call).ListLabels(labels, publicId, level);
+
+    Json::Value result = Json::arrayValue;
+
+    for (std::set<std::string>::const_iterator it = labels.begin(); it != labels.end(); ++it)
+    {
+      result.append(*it);
+    }
+
+    call.GetOutput().AnswerJson(result);
+  }
+  
+
+  static void GetLabel(RestApiGetCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("Test label")
+        .SetDescription("Test whether the " + r + " is associated with the given label")
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .SetUriArgument("label", "The label of interest")
+        .AddAnswerType(MimeType_PlainText, "Empty string is returned in the case of presence, error 404 in the case of absence");
+      return;
+    }
+
+    CheckValidResourceType(call);
+
+    assert(!call.GetFullUri().empty());
+    const std::string publicId = call.GetUriComponent("id", "");
+    const ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::string label = call.GetUriComponent("label", "");
+
+    std::set<std::string> labels;
+    OrthancRestApi::GetIndex(call).ListLabels(labels, publicId, level);
+    
+    if (labels.find(label) != labels.end())
+    {
+      call.GetOutput().AnswerBuffer("", MimeType_PlainText);
+    }
+  }
+
+
+  static void AddLabel(RestApiPutCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("Add label")
+        .SetDescription("Associate a label with a " + r)
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .SetUriArgument("label", "The label to be added");
+      return;
+    }
+
+    CheckValidResourceType(call);
+
+    std::string publicId = call.GetUriComponent("id", "");
+    const ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::string label = call.GetUriComponent("label", "");
+    OrthancRestApi::GetIndex(call).ModifyLabel(publicId, level, label, StatelessDatabaseOperations::LabelOperation_Add);
+
+    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
+  }
+
+
+  static void RemoveLabel(RestApiDeleteCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("Remove label")
+        .SetDescription("Remove a label associated with a " + r)
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .SetUriArgument("label", "The label to be removed");
+      return;
+    }
+
+    CheckValidResourceType(call);
+
+    std::string publicId = call.GetUriComponent("id", "");
+    const ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::string label = call.GetUriComponent("label", "");
+    OrthancRestApi::GetIndex(call).ModifyLabel(publicId, level, label, StatelessDatabaseOperations::LabelOperation_Remove);
+
+    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
+  }
+  
 
   // Handling of attached files -----------------------------------------------
 
@@ -2949,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())
     {
@@ -2978,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;
@@ -3008,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
     {
@@ -3049,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<size_t>(tmp);
@@ -3062,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<size_t>(tmp);
@@ -3085,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();
@@ -3100,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);
@@ -3854,6 +4025,12 @@
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", GetMetadata);
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", SetMetadata);
 
+      // New in Orthanc 1.12.0
+      Register("/" + resourceTypes[i] + "/{id}/labels", ListLabels);
+      Register("/" + resourceTypes[i] + "/{id}/labels/{label}", GetLabel);
+      Register("/" + resourceTypes[i] + "/{id}/labels/{label}", RemoveLabel);
+      Register("/" + resourceTypes[i] + "/{id}/labels/{label}", AddLabel);
+
       Register("/" + resourceTypes[i] + "/{id}/attachments", ListAttachments);
       Register("/" + resourceTypes[i] + "/{id}/attachments/{name}", DeleteAttachment);
       Register("/" + resourceTypes[i] + "/{id}/attachments/{name}", GetAttachmentOperations);
--- a/OrthancServer/Sources/Search/DatabaseLookup.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Search/DatabaseLookup.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -368,4 +368,30 @@
 
     return clone.release();
   }
+
+
+  void DatabaseLookup::AddWithLabel(const std::string& label)
+  {
+    if (label.empty())
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+    else
+    {
+      withLabels_.insert(label);
+    }
+  }
+  
+
+  void DatabaseLookup::AddWithoutLabel(const std::string& label)
+  {
+    if (label.empty())
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+    else
+    {
+      withoutLabels_.insert(label);
+    }
+  }
 }
--- a/OrthancServer/Sources/Search/DatabaseLookup.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Search/DatabaseLookup.h	Tue Apr 04 09:46:40 2023 +0200
@@ -32,6 +32,8 @@
   {
   private:
     std::vector<DicomTagConstraint*>  constraints_;
+    std::set<std::string>             withLabels_;
+    std::set<std::string>             withoutLabels_;
 
     void AddDicomConstraintInternal(const DicomTag& tag,
                                     ValueRepresentation vr,
@@ -92,5 +94,19 @@
     bool HasTag(const DicomTag& tag) const;
 
     void RemoveConstraint(const DicomTag& tag);
+
+    void AddWithLabel(const std::string& label);
+
+    void AddWithoutLabel(const std::string& label);
+
+    const std::set<std::string>& GetWithLabels() const
+    {
+      return withLabels_;
+    }
+
+    const std::set<std::string>& GetWithoutLabels() const
+    {
+      return withoutLabels_;
+    }
   };
 }
--- a/OrthancServer/Sources/Search/ISqlLookupFormatter.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Search/ISqlLookupFormatter.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -39,6 +39,7 @@
 #include "DatabaseConstraint.h"
 
 #include <boost/lexical_cast.hpp>
+#include <list>
 
 
 namespace Orthanc
@@ -268,12 +269,46 @@
                " AND " + tag + ".tagElement = " +
                boost::lexical_cast<std::string>(constraint.GetTag().GetElement()));
   }
+
+
+  static std::string Join(const std::list<std::string>& values,
+                          const std::string& prefix,
+                          const std::string& separator)
+  {
+    if (values.empty())
+    {
+      return "";
+    }
+    else
+    {
+      std::string s = prefix;
+
+      bool first = true;
+      for (std::list<std::string>::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<DatabaseConstraint>& lookup,
                                   ResourceType queryLevel,
+                                  const std::set<std::string>& withLabels,
+                                  const std::set<std::string>& withoutLabels,
                                   size_t limit)
   {
     assert(ResourceType_Patient < ResourceType_Study &&
@@ -346,9 +381,44 @@
               FormatLevel(static_cast<ResourceType>(level - 1)) + ".internalId=" +
               FormatLevel(static_cast<ResourceType>(level)) + ".parentId");
     }
-      
-    sql += (joins + " WHERE " + FormatLevel(queryLevel) + ".resourceType = " +
-            formatter.FormatResourceType(queryLevel) + comparisons);
+
+    std::list<std::string> where;
+
+    if (!withLabels.empty())
+    {
+      std::list<std::string> labels;
+      for (std::set<std::string>::const_iterator it = withLabels.begin(); it != withLabels.end(); ++it)
+      {
+        labels.push_back(formatter.GenerateParameter(*it));
+      }
+
+      where.push_back(boost::lexical_cast<std::string>(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<std::string> labels;
+      for (std::set<std::string>::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)
     {
--- a/OrthancServer/Sources/Search/ISqlLookupFormatter.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/Search/ISqlLookupFormatter.h	Tue Apr 04 09:46:40 2023 +0200
@@ -60,6 +60,8 @@
                       ISqlLookupFormatter& formatter,
                       const std::vector<DatabaseConstraint>& lookup,
                       ResourceType queryLevel,
+                      const std::set<std::string>& withLabels,     // New in Orthanc 1.12.0
+                      const std::set<std::string>& withoutLabels,  // New in Orthanc 1.12.0
                       size_t limit);
   };
 }
--- a/OrthancServer/Sources/ServerContext.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/ServerContext.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -1448,8 +1448,9 @@
     }
 
     {
-      const size_t lookupLimit = (databaseLimit == 0 ? 0 : databaseLimit + 1);      
-      GetIndex().ApplyLookupResources(resources, &instances, *fastLookup, queryLevel, lookupLimit);
+      const size_t lookupLimit = (databaseLimit == 0 ? 0 : databaseLimit + 1);
+      GetIndex().ApplyLookupResources(resources, &instances, *fastLookup, queryLevel,
+                                      lookup.GetWithLabels(), lookup.GetWithoutLabels(), lookupLimit);
     }
 
     bool complete = (databaseLimit == 0 ||
@@ -1542,7 +1543,7 @@
           ComputeStudyTags(resource, *this, resources[i], requestedTags);
 
           std::vector<std::string> modalities;
-          Toolbox::TokenizeString(modalities, resource.tags_.GetValue(DICOM_TAG_MODALITIES_IN_STUDY).GetContent(), '\\');
+          Toolbox::TokenizeString(modalities, resource.GetMainDicomTags().GetValue(DICOM_TAG_MODALITIES_IN_STUDY).GetContent(), '\\');
           bool hasAtLeastOneModalityMatching = false;
           for (size_t m = 0; m < modalities.size(); m++)
           {
@@ -1551,7 +1552,7 @@
 
           isMatch = isMatch && hasAtLeastOneModalityMatching;
           // copy the value of ModalitiesInStudy such that it can be reused to build the answer
-          allMainDicomTagsFromDB.SetValue(DICOM_TAG_MODALITIES_IN_STUDY, resource.tags_.GetValue(DICOM_TAG_MODALITIES_IN_STUDY));
+          allMainDicomTagsFromDB.SetValue(DICOM_TAG_MODALITIES_IN_STUDY, resource.GetMainDicomTags().GetValue(DICOM_TAG_MODALITIES_IN_STUDY));
         }
 
         if (isMatch)
@@ -1990,10 +1991,10 @@
   {
     target = Json::objectValue;
 
-    target["Type"] = GetResourceTypeText(resource.type_, false, true);
-    target["ID"] = resource.id_;
+    target["Type"] = GetResourceTypeText(resource.GetLevel(), false, true);
+    target["ID"] = resource.GetPublicId();
 
-    switch (resource.type_)
+    switch (resource.GetLevel())
     {
       case ResourceType_Patient:
         break;
@@ -2014,7 +2015,7 @@
         throw OrthancException(ErrorCode_InternalError);
     }
 
-    switch (resource.type_)
+    switch (resource.GetLevel())
     {
       case ResourceType_Patient:
       case ResourceType_Study:
@@ -2028,11 +2029,11 @@
           c.append(*it);
         }
 
-        if (resource.type_ == ResourceType_Patient)
+        if (resource.GetLevel() == ResourceType_Patient)
         {
           target["Studies"] = c;
         }
-        else if (resource.type_ == ResourceType_Study)
+        else if (resource.GetLevel() == ResourceType_Study)
         {
           target["Series"] = c;
         }
@@ -2050,7 +2051,7 @@
         throw OrthancException(ErrorCode_InternalError);
     }
 
-    switch (resource.type_)
+    switch (resource.GetLevel())
     {
       case ResourceType_Patient:
       case ResourceType_Study:
@@ -2099,9 +2100,9 @@
       target["ModifiedFrom"] = resource.modifiedFrom_;
     }
 
-    if (resource.type_ == ResourceType_Patient ||
-        resource.type_ == ResourceType_Study ||
-        resource.type_ == ResourceType_Series)
+    if (resource.GetLevel() == ResourceType_Patient ||
+        resource.GetLevel() == ResourceType_Study ||
+        resource.GetLevel() == ResourceType_Series)
     {
       target["IsStable"] = resource.isStable_;
 
@@ -2117,15 +2118,15 @@
     static const char* const PATIENT_MAIN_DICOM_TAGS = "PatientMainDicomTags";
 
     DicomMap mainDicomTags;
-    resource.tags_.ExtractResourceInformation(mainDicomTags, resource.type_);
+    resource.GetMainDicomTags().ExtractResourceInformation(mainDicomTags, resource.GetLevel());
 
     target[MAIN_DICOM_TAGS] = Json::objectValue;
     FromDcmtkBridge::ToJson(target[MAIN_DICOM_TAGS], mainDicomTags, format);
     
-    if (resource.type_ == ResourceType_Study)
+    if (resource.GetLevel() == ResourceType_Study)
     {
       DicomMap patientMainDicomTags;
-      resource.tags_.ExtractPatientInformation(patientMainDicomTags);
+      resource.GetMainDicomTags().ExtractPatientInformation(patientMainDicomTags);
 
       target[PATIENT_MAIN_DICOM_TAGS] = Json::objectValue;
       FromDcmtkBridge::ToJson(target[PATIENT_MAIN_DICOM_TAGS], patientMainDicomTags, format);
@@ -2136,13 +2137,23 @@
       static const char* const REQUESTED_TAGS = "RequestedTags";
 
       DicomMap tags;
-      resource.tags_.ExtractTags(tags, requestedTags);
+      resource.GetMainDicomTags().ExtractTags(tags, requestedTags);
 
       target[REQUESTED_TAGS] = Json::objectValue;
       FromDcmtkBridge::ToJson(target[REQUESTED_TAGS], tags, format);
 
     }
 
+    {
+      Json::Value labels = Json::arrayValue;
+
+      for (std::set<std::string>::const_iterator it = resource.labels_.begin(); it != resource.labels_.end(); ++it)
+      {
+        labels.append(*it);
+      }
+
+      target["Labels"] = labels;
+    }
   }
 
 
@@ -2153,7 +2164,7 @@
   {
     if (requestedTags.count(DICOM_TAG_INSTANCE_AVAILABILITY) > 0)
     {
-      resource.tags_.SetValue(DICOM_TAG_INSTANCE_AVAILABILITY, "ONLINE", false);
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_INSTANCE_AVAILABILITY, "ONLINE", false);
       resource.missingRequestedTags_.erase(DICOM_TAG_INSTANCE_AVAILABILITY);
     }
   }
@@ -2171,7 +2182,7 @@
 
       index.GetChildren(instances, seriesPublicId);
 
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_SERIES_RELATED_INSTANCES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_SERIES_RELATED_INSTANCES,
                               boost::lexical_cast<std::string>(instances.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_SERIES_RELATED_INSTANCES);
     }
@@ -2216,13 +2227,13 @@
       std::string modalities;
       Toolbox::JoinStrings(modalities, values, "\\");
 
-      resource.tags_.SetValue(DICOM_TAG_MODALITIES_IN_STUDY, modalities, false);
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_MODALITIES_IN_STUDY, modalities, false);
       resource.missingRequestedTags_.erase(DICOM_TAG_MODALITIES_IN_STUDY);
     }
 
     if (hasNbRelatedSeries)
     {
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_SERIES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_SERIES,
                               boost::lexical_cast<std::string>(series.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_STUDY_RELATED_SERIES);
     }
@@ -2240,7 +2251,7 @@
 
       if (hasNbRelatedInstances)
       {
-        resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_INSTANCES,
+        resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_INSTANCES,
                                 boost::lexical_cast<std::string>(instances.size()), false);      
         resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_STUDY_RELATED_INSTANCES);
       }
@@ -2264,7 +2275,7 @@
         {
           std::string sopClassUids;
           Toolbox::JoinStrings(sopClassUids, values, "\\");
-          resource.tags_.SetValue(DICOM_TAG_SOP_CLASSES_IN_STUDY, sopClassUids, false);
+          resource.GetMainDicomTags().SetValue(DICOM_TAG_SOP_CLASSES_IN_STUDY, sopClassUids, false);
         }
 
         resource.missingRequestedTags_.erase(DICOM_TAG_SOP_CLASSES_IN_STUDY);
@@ -2291,7 +2302,7 @@
 
     if (hasNbRelatedStudies)
     {
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_STUDIES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_STUDIES,
                               boost::lexical_cast<std::string>(studies.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_STUDIES);
     }
@@ -2308,7 +2319,7 @@
 
       if (hasNbRelatedSeries)
       {
-        resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_SERIES,
+        resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_SERIES,
                                 boost::lexical_cast<std::string>(series.size()), false);
         resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_SERIES);
       }
@@ -2324,7 +2335,7 @@
         instances.splice(instances.end(), thisInstancesIds);
       }
 
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_INSTANCES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_INSTANCES,
                               boost::lexical_cast<std::string>(instances.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_INSTANCES);
     }
@@ -2388,7 +2399,7 @@
   {
     ExpandedResource resource;
 
-    if (ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson, level, requestedTags, ExpandResourceDbFlags_Default, allowStorageAccess))
+    if (ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson, level, requestedTags, ExpandResourceFlags_Default, allowStorageAccess))
     {
       SerializeExpandedResource(target, resource, format, requestedTags);
       return true;
@@ -2404,57 +2415,63 @@
                                      const Json::Value* dicomAsJson,   // optional: the dicom-as-json for the resource (if already available)
                                      ResourceType level,
                                      const std::set<DicomTag>& requestedTags,
-                                     ExpandResourceDbFlags expandFlags,
+                                     ExpandResourceFlags expandFlags,
                                      bool allowStorageAccess)
   {
     // first try to get the tags from what is already available
     
-    if ((expandFlags & ExpandResourceDbFlags_IncludeMainDicomTags)
-      && (mainDicomTags.GetSize() > 0)
-      && (dicomAsJson != NULL))
+    if ((expandFlags & ExpandResourceFlags_IncludeMainDicomTags) &&
+        mainDicomTags.GetSize() > 0 &&
+        dicomAsJson != NULL)
     {
       
-      resource.tags_.Merge(mainDicomTags);
+      resource.GetMainDicomTags().Merge(mainDicomTags);
 
       if (dicomAsJson->isObject())
       {
-        resource.tags_.FromDicomAsJson(*dicomAsJson);
+        resource.GetMainDicomTags().FromDicomAsJson(*dicomAsJson);
       }
 
       std::set<DicomTag> retrievedTags;
       std::set<DicomTag> missingTags;
-      resource.tags_.GetTags(retrievedTags);
+      resource.GetMainDicomTags().GetTags(retrievedTags);
 
       Toolbox::GetMissingsFromSet(missingTags, requestedTags, retrievedTags);
 
       // if all possible tags have been read, no need to get them from DB anymore
       if (missingTags.size() == 0 || DicomMap::HasOnlyComputedTags(missingTags))
       {
-        expandFlags = static_cast<ExpandResourceDbFlags>(expandFlags & ~ExpandResourceDbFlags_IncludeMainDicomTags);
+        expandFlags = static_cast<ExpandResourceFlags>(expandFlags & ~ExpandResourceFlags_IncludeMainDicomTags);
       }
 
-      if (missingTags.size() == 0 && expandFlags == ExpandResourceDbFlags_None)  // we have already retrieved anything we need
+      if (missingTags.size() == 0 && expandFlags == ExpandResourceFlags_None)  // we have already retrieved anything we need
       {
         return true;
       }
     }
 
-    if (expandFlags != ExpandResourceDbFlags_None
-        && GetIndex().ExpandResource(resource, publicId, level, requestedTags, static_cast<ExpandResourceDbFlags>(expandFlags | ExpandResourceDbFlags_IncludeMetadata)))  // we always need the metadata to get the mainDicomTagsSignature
+    if (expandFlags != ExpandResourceFlags_None &&
+        GetIndex().ExpandResource(resource, publicId, level, requestedTags,
+                                  static_cast<ExpandResourceFlags>(expandFlags | ExpandResourceFlags_IncludeMetadata)))  // we always need the metadata to get the mainDicomTagsSignature
     {
       // check the main dicom tags list has not changed since the resource was stored
-      if (resource.mainDicomTagsSignature_ != DicomMap::GetMainDicomTagsSignature(resource.type_))
+      if (resource.mainDicomTagsSignature_ != DicomMap::GetMainDicomTagsSignature(resource.GetLevel()))
       {
         OrthancConfiguration::ReaderLock lock;
         if (lock.GetConfiguration().IsWarningEnabled(Warnings_002_InconsistentDicomTagsInDb))
         {
-          LOG(WARNING) << "W002: " << Orthanc::GetResourceTypeText(resource.type_, false , false) << " has been stored with another version of Main Dicom Tags list, you should POST to /" << Orthanc::GetResourceTypeText(resource.type_, true, false) << "/" << resource.id_ << "/reconstruct to update the list of tags saved in DB.  Some MainDicomTags might be missing from this answer.";
+          LOG(WARNING) << "W002: " << Orthanc::GetResourceTypeText(resource.GetLevel(), false , false)
+                       << " has been stored with another version of Main Dicom Tags list, you should POST to /"
+                       << Orthanc::GetResourceTypeText(resource.GetLevel(), true, false)
+                       << "/" << resource.GetPublicId()
+                       << "/reconstruct to update the list of tags saved in DB.  Some MainDicomTags might be missing from this answer.";
         }
       }
 
       // possibly merge missing requested tags from dicom-as-json
-      if (allowStorageAccess
-          && !resource.missingRequestedTags_.empty() && !DicomMap::HasOnlyComputedTags(resource.missingRequestedTags_))
+      if (allowStorageAccess &&
+          !resource.missingRequestedTags_.empty() &&
+          !DicomMap::HasOnlyComputedTags(resource.missingRequestedTags_))
       {
         OrthancConfiguration::ReaderLock lock;
         if (lock.GetConfiguration().IsWarningEnabled(Warnings_001_TagsBeingReadFromStorage))
@@ -2472,7 +2489,9 @@
           std::string missings;
           FromDcmtkBridge::FormatListOfTags(missings, missingTags);
 
-          LOG(WARNING) << "W001: Accessing Dicom tags from storage when accessing " << Orthanc::GetResourceTypeText(resource.type_, false , false) << " : " << missings;
+          LOG(WARNING) << "W001: Accessing Dicom tags from storage when accessing "
+                       << Orthanc::GetResourceTypeText(resource.GetLevel(), false, false)
+                       << " : " << missings;
         }
 
 
@@ -2508,7 +2527,7 @@
           tagsFromJson.FromDicomAsJson(*dicomAsJson, false /* append */, true /* parseSequences*/);
         }
 
-        resource.tags_.Merge(tagsFromJson);
+        resource.GetMainDicomTags().Merge(tagsFromJson);
       }
 
       // compute the requested tags
--- a/OrthancServer/Sources/ServerContext.h	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/ServerContext.h	Tue Apr 04 09:46:40 2023 +0200
@@ -585,7 +585,7 @@
                         const Json::Value* dicomAsJson,   // optional: the dicom-as-json for the resource
                         ResourceType level,
                         const std::set<DicomTag>& requestedTags,
-                        ExpandResourceDbFlags expandFlags,
+                        ExpandResourceFlags expandFlags,
                         bool allowStorageAccess);
 
     FindStorageAccessMode GetFindStorageAccessMode() const
--- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -675,9 +675,12 @@
       replacePatientMainDicomTags |= DicomMap::IsMainDicomTag(*it, ResourceType_Patient);
     }
 
-    if ((modificationLevel == ResourceType_Study || modificationLevel == ResourceType_Patient)
-        && !modification_->IsReplaced(DICOM_TAG_PATIENT_ID) 
-        && modification_->IsKept(DICOM_TAG_STUDY_INSTANCE_UID) && modification_->IsKept(DICOM_TAG_SERIES_INSTANCE_UID) && modification_->IsKept(DICOM_TAG_SOP_INSTANCE_UID))
+    if ((modificationLevel == ResourceType_Study ||
+         modificationLevel == ResourceType_Patient) &&
+        !modification_->IsReplaced(DICOM_TAG_PATIENT_ID) &&
+        modification_->IsKept(DICOM_TAG_STUDY_INSTANCE_UID) &&
+        modification_->IsKept(DICOM_TAG_SERIES_INSTANCE_UID) &&
+        modification_->IsKept(DICOM_TAG_SOP_INSTANCE_UID))
     {
       // if we keep the SOPInstanceUID, it very likely means that we are modifying existing resources 'in place'
 
@@ -715,9 +718,9 @@
         else
         {
           ExpandedResource originalStudy;
-          if (GetContext().GetIndex().ExpandResource(originalStudy, *studyId, ResourceType_Study, emptyRequestedTags, ExpandResourceDbFlags_IncludeMainDicomTags))
+          if (GetContext().GetIndex().ExpandResource(originalStudy, *studyId, ResourceType_Study, emptyRequestedTags, ExpandResourceFlags_IncludeMainDicomTags))
           {
-            targetPatientId = originalStudy.tags_.GetStringValue(DICOM_TAG_PATIENT_ID, "", false);
+            targetPatientId = originalStudy.GetMainDicomTags().GetStringValue(DICOM_TAG_PATIENT_ID, "", false);
           }
           else
           {
@@ -734,7 +737,7 @@
         {
           ExpandedResource targetPatient;
           
-          if (GetContext().GetIndex().ExpandResource(targetPatient, lookupPatientResult[0], ResourceType_Patient, emptyRequestedTags, static_cast<ExpandResourceDbFlags>(ExpandResourceDbFlags_IncludeMainDicomTags | ExpandResourceDbFlags_IncludeChildren)))
+          if (GetContext().GetIndex().ExpandResource(targetPatient, lookupPatientResult[0], ResourceType_Patient, emptyRequestedTags, static_cast<ExpandResourceFlags>(ExpandResourceFlags_IncludeMainDicomTags | ExpandResourceFlags_IncludeChildren)))
           {
             const std::list<std::string> childrenIds = targetPatient.childrenIds_;
             bool targetPatientHasOtherStudies = childrenIds.size() > 1;
@@ -747,7 +750,7 @@
             {
               // this is allowed if all patient replacedTags do match the target patient tags
               DicomMap targetPatientTags;
-              targetPatient.tags_.ExtractPatientInformation(targetPatientTags);
+              targetPatient.GetMainDicomTags().ExtractPatientInformation(targetPatientTags);
 
               std::set<DicomTag> mainPatientTags;
               DicomMap::GetMainDicomTags(mainPatientTags, ResourceType_Patient);
@@ -755,9 +758,9 @@
               for (std::set<DicomTag>::const_iterator mainPatientTag = mainPatientTags.begin();
                    mainPatientTag != mainPatientTags.end(); ++mainPatientTag)
               {
-                if (targetPatientTags.HasTag(*mainPatientTag) 
-                    && (!modification_->IsReplaced(*mainPatientTag) 
-                        || modification_->GetReplacementAsString(*mainPatientTag) != targetPatientTags.GetStringValue(*mainPatientTag, "", false)))
+                if (targetPatientTags.HasTag(*mainPatientTag) &&
+                    (!modification_->IsReplaced(*mainPatientTag) ||
+                     modification_->GetReplacementAsString(*mainPatientTag) != targetPatientTags.GetStringValue(*mainPatientTag, "", false)))
                 {
                   throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags.  Try using /patients/../modify instead to modify the patient. Failing tag: ") + mainPatientTag->Format());
                 }
@@ -769,8 +772,7 @@
             }
           }
         }
-      }
-      
+      }      
     }
   }
 }
--- a/OrthancServer/UnitTestsSources/ServerIndexTests.cpp	Tue Apr 04 09:46:09 2023 +0200
+++ b/OrthancServer/UnitTestsSources/ServerIndexTests.cpp	Tue Apr 04 09:46:40 2023 +0200
@@ -167,8 +167,9 @@
       
       std::vector<DatabaseConstraint> lookup;
       lookup.push_back(c.ConvertToDatabaseConstraint(level, DicomTagType_Identifier));
-      
-      transaction_->ApplyLookupResources(result, NULL, lookup, level, 0 /* no limit */);
+
+      std::set<std::string> noLabel;
+      transaction_->ApplyLookupResources(result, NULL, lookup, level, noLabel, noLabel, 0 /* no limit */);
     }    
 
     void DoLookupIdentifier2(std::list<std::string>& result,
@@ -188,7 +189,8 @@
       lookup.push_back(c1.ConvertToDatabaseConstraint(level, DicomTagType_Identifier));
       lookup.push_back(c2.ConvertToDatabaseConstraint(level, DicomTagType_Identifier));
       
-      transaction_->ApplyLookupResources(result, NULL, lookup, level, 0 /* no limit */);
+      std::set<std::string> noLabel;
+      transaction_->ApplyLookupResources(result, NULL, lookup, level, noLabel, noLabel, 0 /* no limit */);
     }
   };
 }