Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
[Core] `EntityReference` container interoperability
## What

Design and implement convenience functionality for handling and querying `EntityReference` objects.

Especially in Python, Eg: as keys in a dict etc.

```
TypeError: unhashable type: 'openassetio._openassetio.EntityReference'
```

## Why

Entity references used to be simple strings.  Now they are a strong type and lose the conveniences that come with strings. See https://github.com/OpenAssetIO/OpenAssetIO/pull/567#discussion_r945649223


## Requirements

### User

- As a developer integrating OpenAssetIO, I need to be able to use entity references where I would normally use strings, such as map keys, or passing to a string formatting library. Without this, it adds significant unnecessary complexity to my integration.

- As a developer integrating OpenAssetIO I should still be protected by the strong-typing protections for OpenAssetIO API methods, to avoid programming errors.

## Acceptance Criteria
- Implement all possible dunder methods for python EntityReference
- EntityReference is usable in STL containers, implementing std::hash etc


Discussion / hints:

Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 61ebde983..fd0bb6aad 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -15,6 +15,13 @@ managers._
   object workflows.
   [#1170](https://github.com/OpenAssetIO/OpenAssetIO/issues/1170)
 
+## Improvements
+
+- Added operators and hash functions to the `EntityReference` type, in
+  both C++ and Python, such that `EntityReference` objects can be used
+  as keys in associative containers (e.g. `dict`/`std::unordered_map`).
+  [#573](https://github.com/OpenAssetIO/OpenAssetIO/issues/573)
+
 ## Bug fixes
 
 - Added "raise from" behaviour in C++->Python exception translation, in
diff --git a/src/openassetio-core/include/openassetio/EntityReference.hpp b/src/openassetio-core/include/openassetio/EntityReference.hpp
index 0d5347413..573282560 100644
--- a/src/openassetio-core/include/openassetio/EntityReference.hpp
+++ b/src/openassetio-core/include/openassetio/EntityReference.hpp
@@ -1,5 +1,5 @@
 // SPDX-License-Identifier: Apache-2.0
-// Copyright 2022 The Foundry Visionmongers Ltd
+// Copyright 2024 The Foundry Visionmongers Ltd
 #pragma once
 
 #include <utility>
@@ -41,16 +41,68 @@ class EntityReference final {
       : entityReferenceString_(std::move(entityReferenceString)) {}
 
   /**
-   * Compare the contents of this reference with another for equality.
+   * Compare this reference with another lexicographically.
    *
-   * @param other Entity refernce to compare against.
+   * @param other Entity reference to compare against.
    *
    * @return `true` if contents are equal, `false` otherwise.
    */
-  bool operator==(const EntityReference& other) const {
+  [[nodiscard]] bool operator==(const EntityReference& other) const {
     return other.entityReferenceString_ == entityReferenceString_;
   }
 
+  /**
+   * Compare this reference with another lexicographically.
+   *
+   * @param other Entity reference to compare against.
+   *
+   * @return `true` if contents are not equal, `false` otherwise.
+   */
+  [[nodiscard]] bool operator!=(const EntityReference& other) const { return !(*this == other); }
+
+  /**
+   * Compare this reference with another lexicographically.
+   *
+   * @param other Entity reference to compare against.
+   *
+   * @return `true` if this reference is less than other, `false`
+   * otherwise.
+   */
+  [[nodiscard]] bool operator<(const EntityReference& other) const {
+    return entityReferenceString_ < other.entityReferenceString_;
+  }
+
+  /**
+   * Compare this reference with another lexicographically.
+   *
+   * @param other Entity reference to compare against.
+   *
+   * @return `true` if this reference is less or equal than other,
+   * `false` otherwise.
+   */
+  [[nodiscard]] bool operator<=(const EntityReference& other) const {
+    return entityReferenceString_ <= other.entityReferenceString_;
+  }
+
+  /**
+   * Compare this reference with another lexicographically.
+   *
+   * @param other Entity reference to compare against.
+   *
+   * @return `true` if this reference is greater than other, `false`
+   * otherwise.
+   */
+  [[nodiscard]] bool operator>(const EntityReference& other) const { return !(*this <= other); }
+
+  /**
+   * Compare this reference with another lexicographically.
+   *
+   * @param other Entity reference to compare against.
+   *
+   * @return `true` if this reference is greater or equal than other,
+   * `false` otherwise.
+   */
+  [[nodiscard]] bool operator>=(const EntityReference& other) const { return !(*this < other); }
   /**
    * @return The string representation of this entity reference.
    */
@@ -62,8 +114,34 @@ class EntityReference final {
 
 static_assert(std::is_move_constructible_v<EntityReference>);
 static_assert(std::is_move_assignable_v<EntityReference>);
+static_assert(std::is_copy_constructible_v<EntityReference>);
+static_assert(std::is_copy_assignable_v<EntityReference>);
 
 /// A list of entity references, used or batch-first functions.
 using EntityReferences = std::vector<EntityReference>;
 }  // namespace OPENASSETIO_CORE_ABI_VERSION
 }  // namespace openassetio
+
+namespace std {
+template <>
+struct hash<openassetio::EntityReference> {
+  std::size_t operator()(const openassetio::EntityReference& ref) const noexcept {
+    // Construct hash using common hashing function found
+    // across the literature, e.g. boost::hash_combine. I.e.
+    // seed ^= hasher(value) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
+
+    // int(2^32 / phi) (where phi is the golden ratio).
+    constexpr std::size_t kInvPhi = 0x9e3779b9;
+    // Small coprime shift distances to spread out the bits.
+    constexpr std::size_t kLShift = 6;
+    constexpr std::size_t kRShift = 2;
+    // Seed incorporating type hash, assuming initial seed of 0
+    // (note: hash_code() is not constexpr in C++17).
+    static const std::size_t kSeed = typeid(openassetio::EntityReference).hash_code() + kInvPhi;
+    // Precompute rhs of hash equation.
+    static const std::size_t kBitMixer = kInvPhi + (kSeed << kLShift) + (kSeed >> kRShift);
+
+    return kSeed ^ (std::hash<openassetio::Str>{}(ref.toString()) + kBitMixer);
+  }
+};
+}  // namespace std
diff --git a/src/openassetio-python/cmodule/src/EntityReferenceBinding.cpp b/src/openassetio-python/cmodule/src/EntityReferenceBinding.cpp
index 89a1aa81b..329c9f004 100644
--- a/src/openassetio-python/cmodule/src/EntityReferenceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/EntityReferenceBinding.cpp
@@ -30,5 +30,9 @@ void registerEntityReference(const py::module& mod) {
              stringStream << self;
              return fmt::format("EntityReference('{}')", stringStream.str());
            })
-      .def(py::self == py::self);  // NOLINT(misc-redundant-expression)
+      .def(py::self == py::self)  // NOLINT(misc-redundant-expression)
+      .def(py::self < py::self)   // NOLINT(misc-redundant-expression)
+      .def(py::self <= py::self)  // NOLINT(misc-redundant-expression)
+      .def("__hash__",
+           [](const EntityReference& self) { return std::hash<EntityReference>{}(self); });
 }


Test patch:
diff --git a/src/openassetio-core/tests/CMakeLists.txt b/src/openassetio-core/tests/CMakeLists.txt
index 325279bd7..d9090faa5 100644
--- a/src/openassetio-core/tests/CMakeLists.txt
+++ b/src/openassetio-core/tests/CMakeLists.txt
@@ -23,6 +23,7 @@ target_sources(openassetio-core-cpp-test-exe
     typedefsTest.cpp
     BatchElementErrorTest.cpp
     ContextTest.cpp
+    EntityReferenceTest.cpp
     TraitsDataTest.cpp
     versionTest.cpp
     hostApi/ManagerTest.cpp
diff --git a/src/openassetio-core/tests/EntityReferenceTest.cpp b/src/openassetio-core/tests/EntityReferenceTest.cpp
new file mode 100644
index 000000000..a040d2ef0
--- /dev/null
+++ b/src/openassetio-core/tests/EntityReferenceTest.cpp
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2024 The Foundry Visionmongers Ltd
+#include <map>
+#include <set>
+#include <unordered_set>
+
+#include <catch2/catch.hpp>
+
+#include <openassetio/EntityReference.hpp>
+
+SCENARIO("EntityReference used in an ordered container") {
+  using openassetio::EntityReference;
+  GIVEN("an ordered container") {
+    std::set<EntityReference> container;
+    WHEN("an entity reference is added") {
+      container.emplace("foo");
+      container.emplace("foo");
+      container.emplace("bar");
+      THEN("it is found in the container") {
+        CHECK(container.size() == 2);
+        CHECK(container.count(EntityReference{"foo"}) == 1);
+        CHECK(container.count(EntityReference{"bar"}) == 1);
+      }
+    }
+  }
+}
+
+SCENARIO("EntityReference used in an unordered container") {
+  using openassetio::EntityReference;
+  GIVEN("an unordered container") {
+    std::unordered_set<EntityReference> container;
+    WHEN("an entity reference is added") {
+      container.emplace("foo");
+      container.emplace("foo");
+      container.emplace("bar");
+      THEN("it is found in the container") {
+        CHECK(container.size() == 2);
+        CHECK(container.count(EntityReference{"foo"}) == 1);
+        CHECK(container.count(EntityReference{"bar"}) == 1);
+      }
+    }
+  }
+}
+
+SCENARIO("EntityReference equality and ordering") {
+  using openassetio::EntityReference;
+  GIVEN("two equal entity references") {
+    const EntityReference ref1{"foo"};
+    const EntityReference ref2{"foo"};
+
+    WHEN("they are compared for equality") {
+      THEN("they are equal") { CHECK(ref1 == ref2); }
+    }
+    WHEN("they are compared for non-equality") {
+      THEN("they are equal") { CHECK_FALSE(ref1 != ref2); }
+    }
+    WHEN("they are compared less than or equal") {
+      THEN("they are equal") {
+        CHECK(ref1 <= ref2);
+        CHECK(ref2 <= ref1);
+      }
+    }
+    WHEN("they are compared greater than or equal") {
+      THEN("they are equal") {
+        CHECK(ref1 >= ref2);
+        CHECK(ref2 >= ref1);
+      }
+    }
+  }
+  GIVEN("two unequal entity references") {
+    const EntityReference ref1{"bar"};
+    const EntityReference ref2{"foo"};
+
+    WHEN("they are compared for non-equality") {
+      THEN("they are not equal") { CHECK_FALSE(ref1 == ref2); }
+    }
+    WHEN("they are compared for non-equality") {
+      THEN("they are not equal") { CHECK(ref1 != ref2); }
+    }
+    WHEN("they are compared for greater than") {
+      THEN("they are ordered lexicographically") { CHECK(ref1 < ref2); }
+    }
+    WHEN("they are compared for greater than") {
+      THEN("they are ordered lexicographically") { CHECK(ref2 > ref1); }
+    }
+    WHEN("they are compared less than or equal") {
+      THEN("they are ordered lexicographically") { CHECK(ref1 <= ref2); }
+    }
+    WHEN("they are compared greater than or equal") {
+      THEN("they are ordered lexicographically") { CHECK(ref2 >= ref1); }
+    }
+  }
+}
diff --git a/src/openassetio-python/tests/package/test_entityreference.py b/src/openassetio-python/tests/package/test_entityreference.py
index 56a71dbfa..4047e14b4 100644
--- a/src/openassetio-python/tests/package/test_entityreference.py
+++ b/src/openassetio-python/tests/package/test_entityreference.py
@@ -49,6 +49,32 @@ def test_when_not_same_then_compares_unequal(self):
         assert EntityReference("something") != EntityReference("something else")
 
 
+class Test_EntityReference_ordering:
+    def test_when_less_than_then_compares_lesser(self):
+        assert EntityReference("a") < EntityReference("b")
+
+    def test_when_greater_than_then_compares_greater(self):
+        assert EntityReference("b") > EntityReference("a")
+
+    def test_when_greater_or_equal_than_then_compares_greater_or_equal(self):
+        assert EntityReference("b") >= EntityReference("a")
+        assert EntityReference("a") >= EntityReference("a")
+
+    def test_when_less_or_equal_than_then_compares_lesser_or_equal(self):
+        assert EntityReference("a") <= EntityReference("b")
+        assert EntityReference("a") <= EntityReference("a")
+
+
+class Test_EntityReference_hash:
+    def test_when_refs_used_in_associative_container_then_deduped_appropriately(self):
+        a_ref = EntityReference("value")
+        b_ref = EntityReference("value")
+        c_ref = EntityReference("different value")
+        d_str = "value"
+
+        assert {a_ref, b_ref, c_ref, d_str} == {a_ref, c_ref, d_str}
+
+
 class Test_EntityReference_string_equivalence:
     def test_when_used_with_format_then_result_contains_toString_value(self):
         a_ref = EntityReference("Some 🍟 with that?")