Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
[Core] Support `kField_EntityReferencesMatchPrefix`
## What

Add support in the C++ implementation of `isEntityReferenceString` for a prefix-based default via the `kField_EntityReferencesMatchPrefix` property of the manager's `info()` dictionary; such that when this key is provided, the `Manager` wrapper doesn't invoke the corresponding method of the held interface.

## Why

When a Python based manager implementation is used, the cost of acquiring the GIL and entering Python in order to invoke the a simple string prefix check can be prohibitive.

We have several docstrings that refer to the `kField_EntityReferencesMatchPrefix` optimisation/convenience of `isEntityReferenceString` . However, it is not currently supported, making the docs invalid.

## ACs

- When `isEntityReferenceString` is called on `Manager`, if the interfaces info provides the aforementioned key then the implementations corresponding method is not called, and the method returns if the input string begins with the specified prefix.
-  `info` should only be called once per `Manager` instantiation`


## Notes

Caused by work on #549.

A prerequisite is actually migrating the constant to C++, i.e. #354
[Core] Support `kField_EntityReferencesMatchPrefix`
## What

Add support in the C++ implementation of `isEntityReferenceString` for a prefix-based default via the `kField_EntityReferencesMatchPrefix` property of the manager's `info()` dictionary; such that when this key is provided, the `Manager` wrapper doesn't invoke the corresponding method of the held interface.

## Why

When a Python based manager implementation is used, the cost of acquiring the GIL and entering Python in order to invoke the a simple string prefix check can be prohibitive.

We have several docstrings that refer to the `kField_EntityReferencesMatchPrefix` optimisation/convenience of `isEntityReferenceString` . However, it is not currently supported, making the docs invalid.

## ACs

- When `isEntityReferenceString` is called on `Manager`, if the interfaces info provides the aforementioned key then the implementations corresponding method is not called, and the method returns if the input string begins with the specified prefix.
-  `info` should only be called once per `Manager` instantiation`


## Notes

Caused by work on #549.

A prerequisite is actually migrating the constant to C++, i.e. #354


Discussion / hints:

Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 3186e5ad0..9d8e143ab 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -77,6 +77,16 @@ v1.0.0-alpha.xx
   chance to clean up between tests.
   [#994](https://github.com/OpenAssetIO/OpenAssetIO/issues/994)
 
+### Bug fixes
+
+- Reintroduced the optional optimized entity reference prefix check
+  for `isEntityReferenceString`, allowing the plugin's implementation to
+  be short-circuited. In particular, if the plugin's implementation is
+  written in Python, then a prefix check short-circuits the need for a
+  costly Python function call for this hot code path.
+  [#566](https://github.com/OpenAssetIO/OpenAssetIO/issues/566)
+
+
 v1.0.0-alpha.12
 ---------------
 
diff --git a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
index 88e196061..a7097d540 100644
--- a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
+++ b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
@@ -1618,6 +1618,8 @@ class OPENASSETIO_CORE_EXPORT Manager {
 
   managerApi::ManagerInterfacePtr managerInterface_;
   managerApi::HostSessionPtr hostSession_;
+
+  std::optional<openassetio::Str> entityReferencePrefix_;
 };
 }  // namespace hostApi
 }  // namespace OPENASSETIO_CORE_ABI_VERSION
diff --git a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
index 3448ad07d..7000a46ee 100644
--- a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
+++ b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
@@ -224,8 +224,8 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * There are certain optional keys that may be used by a host or
    * the API:
    *
-   *   @li openassetio.constants.kInfoKey_SmallIcon (upto 32x32)
-   *   @li openassetio.constants.kInfoKey_Icon (any size)
+   *   @li @ref constants.kInfoKey_SmallIcon (upto 32x32)
+   *   @li @ref constants.kInfoKey_Icon (any size)
    *
    * Because it can often be expensive to bridge between languages,
    * info can also contain an additional field - a prefix that
@@ -237,7 +237,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * not. Note, not all invocations require this optimization, so
    * @ref isEntityReferenceString should be implemented regardless.
    *
-   *   @li openassetio.constants.kInfoKey_EntityReferencesMatchPrefix
+   *   @li @ref constants.kInfoKey_EntityReferencesMatchPrefix
    *
    * @return Map of info string key to primitive value.
    */
@@ -546,10 +546,16 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
 
   /**
    * Determines if the supplied string (in its entirety) matches the
-   * pattern of a valid @ref entity_reference in your system. It
-   * does not need to verify that it points to a valid entity in the
-   * system, simply that the pattern of the string is recognised by
-   * this implementation.
+   * pattern of a valid @ref entity_reference in your system.
+   *
+   * It does not need to verify that it points to a valid entity in the
+   * system, simply that the pattern of the string is recognised by this
+   * implementation.
+   *
+   * @note If possible, consider supplying a @ref
+   * constants.kInfoKey_EntityReferencesMatchPrefix "prefix" in your
+   * @ref info() dictionary, so that calls to this method can be
+   * circumvented by performing a (fast) string prefix check instead.
    *
    * Return `True` if the string is an @ref entity_reference
    * and should be considered usable with the other methods of this
diff --git a/src/openassetio-core/src/hostApi/Manager.cpp b/src/openassetio-core/src/hostApi/Manager.cpp
index 93da09caf..e4bb023c2 100644
--- a/src/openassetio-core/src/hostApi/Manager.cpp
+++ b/src/openassetio-core/src/hostApi/Manager.cpp
@@ -5,8 +5,10 @@
 
 #include <openassetio/Context.hpp>
 #include <openassetio/TraitsData.hpp>
+#include <openassetio/constants.hpp>
 #include <openassetio/hostApi/EntityReferencePager.hpp>
 #include <openassetio/hostApi/Manager.hpp>
+#include <openassetio/log/LoggerInterface.hpp>
 #include <openassetio/managerApi/EntityReferencePagerInterface.hpp>
 #include <openassetio/managerApi/HostSession.hpp>
 #include <openassetio/managerApi/ManagerInterface.hpp>
@@ -37,7 +39,34 @@ void throwFromBatchElementError(std::size_t index, BatchElementError error) {
   }
 }
 
+/**
+ * Extract the entity reference prefix from a manager plugin's info
+ * dictionary, if available.
+ */
+std::optional<Str> entityReferencePrefixFromInfo(const log::LoggerInterfacePtr &logger,
+                                                 const InfoDictionary &info) {
+  // Check if the info dict has the prefix key.
+  if (auto iter = info.find(Str{constants::kInfoKey_EntityReferencesMatchPrefix});
+      iter != info.end()) {
+    if (const auto *prefixPtr = std::get_if<openassetio::Str>(&iter->second)) {
+      std::string msg = "Entity reference prefix '";
+      msg += *prefixPtr;
+      msg +=
+          "' provided by manager's info() dict. Subsequent calls to isEntityReferenceString will"
+          " use this prefix rather than call the manager's implementation.";
+      logger->debugApi(msg);
+
+      return *prefixPtr;
+    }
+
+    logger->warning("Entity reference prefix given but is an invalid type: should be a string.");
+  }
+
+  // Prefix string not found, so return unset optional.
+  return {};
+}
 }  // namespace
+
 namespace hostApi {
 
 ManagerPtr Manager::make(managerApi::ManagerInterfacePtr managerInterface,
@@ -60,6 +89,9 @@ InfoDictionary Manager::settings() const { return managerInterface_->settings(ho
 
 void Manager::initialize(InfoDictionary managerSettings) {
   managerInterface_->initialize(std::move(managerSettings), hostSession_);
+
+  entityReferencePrefix_ =
+      entityReferencePrefixFromInfo(hostSession_->logger(), managerInterface_->info());
 }
 
 void Manager::flushCaches() { managerInterface_->flushCaches(hostSession_); }
@@ -104,7 +136,11 @@ ContextPtr Manager::contextFromPersistenceToken(const Str &token) {
 }
 
 bool Manager::isEntityReferenceString(const Str &someString) const {
-  return managerInterface_->isEntityReferenceString(someString, hostSession_);
+  if (!entityReferencePrefix_) {
+    return managerInterface_->isEntityReferenceString(someString, hostSession_);
+  }
+
+  return someString.rfind(*entityReferencePrefix_, 0) != Str::npos;
 }
 
 const Str kCreateEntityReferenceErrorMessage = "Invalid entity reference: ";


Test patch:
diff --git a/src/openassetio-python/tests/conftest.py b/src/openassetio-python/tests/conftest.py
index 6017f2a03..038e650c1 100644
--- a/src/openassetio-python/tests/conftest.py
+++ b/src/openassetio-python/tests/conftest.py
@@ -106,12 +106,12 @@ def mock_entity_reference_pager_interface():
 
 
 @pytest.fixture
-def mock_manager_interface():
+def mock_manager_interface(create_mock_manager_interface):
     """
     Fixture for a `ManagerInterface` that asserts parameter types and
     forwards method calls to an internal public `mock.Mock` instance.
     """
-    return ValidatingMockManagerInterface()
+    return create_mock_manager_interface()
 
 
 @pytest.fixture
@@ -125,7 +125,9 @@ def create_mock_manager_interface():
     """
 
     def creator():
-        return ValidatingMockManagerInterface()
+        manager_interface = ValidatingMockManagerInterface()
+        manager_interface.mock.info.return_value = {}
+        return manager_interface
 
     return creator
 
diff --git a/src/openassetio-python/tests/package/hostApi/test_manager.py b/src/openassetio-python/tests/package/hostApi/test_manager.py
index 7b34250e1..9b1143f83 100644
--- a/src/openassetio-python/tests/package/hostApi/test_manager.py
+++ b/src/openassetio-python/tests/package/hostApi/test_manager.py
@@ -35,6 +35,7 @@
     EntityReference,
     TraitsData,
     managerApi,
+    constants,
 )
 from openassetio.hostApi import Manager, EntityReferencePager
 from openassetio.managerApi import EntityReferencePagerInterface
@@ -201,6 +202,36 @@ def test_wraps_the_corresponding_method_of_the_held_interface(
 
         mock_manager_interface.mock.initialize.assert_called_once_with(a_dict, a_host_session)
 
+    def test_when_entity_ref_prefix_given_then_debug_log_printed(
+        self, manager, mock_manager_interface, mock_logger
+    ):
+        mock_manager_interface.mock.info.return_value = {
+            constants.kInfoKey_EntityReferencesMatchPrefix: "someprefix:"
+        }
+
+        manager.initialize({})
+
+        mock_logger.mock.log.assert_called_once_with(
+            mock_logger.Severity.kDebugApi,
+            "Entity reference prefix 'someprefix:' provided by manager's info() dict."
+            " Subsequent calls to isEntityReferenceString will use this prefix rather"
+            " than call the manager's implementation.",
+        )
+
+    def test_when_entity_ref_prefix_type_invalid_then_debug_log_printed(
+        self, manager, mock_manager_interface, mock_logger
+    ):
+        mock_manager_interface.mock.info.return_value = {
+            constants.kInfoKey_EntityReferencesMatchPrefix: 123
+        }
+
+        manager.initialize({})
+
+        mock_logger.mock.log.assert_called_once_with(
+            mock_logger.Severity.kWarning,
+            "Entity reference prefix given but is an invalid type: should be a string.",
+        )
+
 
 class Test_Manager_flushCaches:
     def test_method_defined_in_cpp(self, method_introspector):
@@ -230,6 +261,31 @@ def test_wraps_the_corresponding_method_of_the_held_interface(
         assert manager.isEntityReferenceString(a_ref_string) == expected
         method.assert_called_once_with(a_ref_string, a_host_session)
 
+    @pytest.mark.parametrize(
+        "prefix,entity_ref,expected",
+        (
+            ("asset://", "asset://my_asset", True),
+            ("asset://", "/home/user/my_asset", False),
+            ("a", "asset://my_asset", True),
+            ("asset://my_asset", "asset://my_asset", True),
+            ("asset://my_asset/long_prefix/", "asset://my_asset", False),
+            ("my📹manager⚡", "my📹manager⚡my_asset⚡", True),
+            ("my📹manager⚡", "my📹manager☁️my_asset⚡", False),
+        ),
+    )
+    def test_when_prefix_given_in_info_then_prefix_used_and_interface_not_called(
+        self, manager, mock_manager_interface, prefix, entity_ref, expected
+    ):
+        mock_manager_interface.mock.info.return_value = {
+            constants.kInfoKey_EntityReferencesMatchPrefix: prefix
+        }
+        manager.initialize({})
+
+        actual = manager.isEntityReferenceString(entity_ref)
+
+        assert not mock_manager_interface.mock.isEntityReferenceString.called
+        assert actual is expected
+
 
 class Test_Manager_createEntityReference:
     def test_method_defined_in_cpp(self, method_introspector):