From c258fac78675da62ba1e8e1234b6ecabffa7069f Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Thu, 9 Oct 2025 18:21:28 +0200 Subject: [PATCH] Add methods for retrieving first and last capabilities in Composition class --- docs/api-reference.md | 22 ++ docs/examples.md | 57 ++++ .../GetFirstTests.cs | 241 +++++++++++++++++ src/Cocoar.Capabilities.Tests/GetLastTests.cs | 251 ++++++++++++++++++ src/Cocoar.Capabilities/Composition.cs | 72 +++++ src/Cocoar.Capabilities/IComposition.cs | 12 + 6 files changed, 655 insertions(+) create mode 100644 src/Cocoar.Capabilities.Tests/GetFirstTests.cs create mode 100644 src/Cocoar.Capabilities.Tests/GetLastTests.cs diff --git a/docs/api-reference.md b/docs/api-reference.md index e2e385a..c383e6a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -139,7 +139,14 @@ Immutable collection of capabilities attached to a subject. Thread-safe. | Method | Returns | Description | |--------|---------|-------------| | `GetAll()` | `IReadOnlyList` | Retrieves all capabilities of the specified type in order | +| `GetFirstOrDefault()` | `TCapability?` | Gets the first capability of the specified type, or null if none exists | +| `GetRequiredFirst()` | `TCapability` | Gets the first capability of the specified type (throws if not found) | +| `TryGetFirst(out TCapability capability)` | `bool` | Tries to get the first capability of the specified type | +| `GetLastOrDefault()` | `TCapability?` | Gets the last capability of the specified type, or null if none exists | +| `GetRequiredLast()` | `TCapability` | Gets the last capability of the specified type (throws if not found) | +| `TryGetLast(out TCapability capability)` | `bool` | Tries to get the last capability of the specified type | | `Has()` | `bool` | Checks if any capability of the specified type exists | +| `Count()` | `int` | Gets the count of capabilities of the specified type | ### Primary Capability Methods @@ -160,6 +167,8 @@ Immutable collection of capabilities attached to a subject. Thread-safe. |--------|-----------|-----------| | `GetPrimary()` | `InvalidOperationException` | If no primary capability exists | | `GetRequiredPrimaryAs()` | `InvalidOperationException` | If primary capability doesn't exist or isn't of the specified type | +| `GetRequiredFirst()` | `InvalidOperationException` | If no capability of the specified type exists | +| `GetRequiredLast()` | `InvalidOperationException` | If no capability of the specified type exists | ### Example @@ -168,6 +177,19 @@ Immutable collection of capabilities attached to a subject. Thread-safe. var validators = composition.GetAll(); var hasLogging = composition.Has(); +// Get first capability (convenient when you expect only one) +var config = composition.GetFirstOrDefault(); +if (config != null) +{ + // Use config +} + +// Or use Try pattern +if (composition.TryGetFirst(out var cfg)) +{ + // Use cfg +} + // Work with primary if (composition.TryGetPrimary(out var primary)) { diff --git a/docs/examples.md b/docs/examples.md index 6700841..38fb9ce 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -45,6 +45,63 @@ if (composition.Has()) { Console.WriteLine("Document can be printed"); } + +// Get first capability (convenient when you expect only one) +var printCap = composition.GetFirstOrDefault(); +if (printCap != null) +{ + printCap.Print(document); +} +``` + +### Getting Single Capabilities + +When you know there's only one capability of a type, use `GetFirstOrDefault` or `TryGetFirst`: + +```csharp +var composition = scope.For(application) + .Add(new ConfigurationCapability("appsettings.json")) + .Add(new LoggingCapability("app.log")) + .Build(); + +// GetFirstOrDefault returns null if not found +var config = composition.GetFirstOrDefault(); +if (config != null) +{ + var setting = config.GetSetting("Key"); +} + +// TryGetFirst uses out parameter pattern +if (composition.TryGetFirst(out var logger)) +{ + logger.Log("Application started"); +} + +// GetRequiredFirst throws if not found (useful when capability is mandatory) +try +{ + var cache = composition.GetRequiredFirst(); + cache.Store("key", value); +} +catch (InvalidOperationException ex) +{ + Console.WriteLine($"Required capability not found: {ex.Message}"); +} + +// GetLast methods - useful for "override" or "last wins" scenarios +var overrideConfig = composition.GetLastOrDefault(); +if (overrideConfig != null) +{ + // Use the last registered override (highest priority) + ApplyConfig(overrideConfig); +} + +// TryGetLast with out parameter +if (composition.TryGetLast(out var theme)) +{ + // Apply the most recently added theme + ApplyTheme(theme); +} ``` ### Working with Multiple Capabilities of Same Type diff --git a/src/Cocoar.Capabilities.Tests/GetFirstTests.cs b/src/Cocoar.Capabilities.Tests/GetFirstTests.cs new file mode 100644 index 0000000..389834f --- /dev/null +++ b/src/Cocoar.Capabilities.Tests/GetFirstTests.cs @@ -0,0 +1,241 @@ +using Xunit; + +namespace Cocoar.Capabilities.Tests; + +public class GetFirstTests +{ + [Fact] + public void GetFirstOrDefault_WithNoCapabilities_ReturnsNull() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject).Build(); + + var result = composition.GetFirstOrDefault(); + + Assert.Null(result); + } + + [Fact] + public void GetFirstOrDefault_WithOneCapability_ReturnsThatCapability() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var capability = new TestCapability("First"); + var composition = scope.For(subject) + .Add(capability) + .Build(); + + var result = composition.GetFirstOrDefault(); + + Assert.NotNull(result); + Assert.Same(capability, result); + Assert.Equal("First", result.Name); + } + + [Fact] + public void GetFirstOrDefault_WithMultipleCapabilities_ReturnsFirstInOrder() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject) + .Add(new TestCapability("Third"), order: 30) + .Add(new TestCapability("First"), order: 10) + .Add(new TestCapability("Second"), order: 20) + .Build(); + + var result = composition.GetFirstOrDefault(); + + Assert.NotNull(result); + Assert.Equal("First", result!.Name); + } + + [Fact] + public void TryGetFirst_WithNoCapabilities_ReturnsFalse() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject).Build(); + + var found = composition.TryGetFirst(out var result); + + Assert.False(found); + Assert.Null(result); + } + + [Fact] + public void TryGetFirst_WithOneCapability_ReturnsTrueAndCapability() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var capability = new TestCapability("Only"); + var composition = scope.For(subject) + .Add(capability) + .Build(); + + var found = composition.TryGetFirst(out var result); + + Assert.True(found); + Assert.NotNull(result); + Assert.Same(capability, result); + Assert.Equal("Only", result.Name); + } + + [Fact] + public void TryGetFirst_WithMultipleCapabilities_ReturnsTrueAndFirstInOrder() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject) + .Add(new TestCapability("C"), order: 3) + .Add(new TestCapability("A"), order: 1) + .Add(new TestCapability("B"), order: 2) + .Build(); + + var found = composition.TryGetFirst(out var result); + + Assert.True(found); + Assert.NotNull(result); + Assert.Equal("A", result!.Name); + } + + [Fact] + public void GetFirstOrDefault_WithDifferentTypes_ReturnsCorrectType() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject) + .Add(new TestCapability("TestCap")) + .Add(new DocumentCapability("DocType", "Content")) + .Build(); + + var testResult = composition.GetFirstOrDefault(); + var docResult = composition.GetFirstOrDefault(); + + Assert.NotNull(testResult); + Assert.Equal("TestCap", testResult!.Name); + + Assert.NotNull(docResult); + Assert.Equal("DocType", docResult!.Type); + } + + [Fact] + public void TryGetFirst_WithRegistry_WorksCorrectly() + { + var options = new CapabilityScopeOptions + { + UseCompositionRegistry = true + }; + using var scope = new CapabilityScope(options); + var subject = new StringSubject("registry-test"); + + var composition = scope.For(subject, useRegistry: true) + .Add(new TestCapability("First")) + .Add(new TestCapability("Second")) + .Build(useRegistry: true); + + var retrieved = scope.Compositions.FindOrDefault(subject); + Assert.NotNull(retrieved); + + var found = retrieved!.TryGetFirst(out var result); + + Assert.True(found); + Assert.Equal("First", result!.Name); + } + + [Fact] + public void GetFirstOrDefault_WithNoOrderSpecified_ReturnsFirstAdded() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject) + .Add(new TestCapability("FirstAdded")) + .Add(new TestCapability("SecondAdded")) + .Add(new TestCapability("ThirdAdded")) + .Build(); + + var result = composition.GetFirstOrDefault(); + + Assert.NotNull(result); + Assert.Equal("FirstAdded", result!.Name); + } + + [Fact] + public void GetRequiredFirst_WithNoCapabilities_ThrowsInvalidOperationException() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject).Build(); + + var ex = Assert.Throws(() => + composition.GetRequiredFirst()); + + Assert.Contains("Capability of type 'TestCapability' not found", ex.Message); + } + + [Fact] + public void GetRequiredFirst_WithOneCapability_ReturnsThatCapability() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var capability = new TestCapability("Required"); + var composition = scope.For(subject) + .Add(capability) + .Build(); + + var result = composition.GetRequiredFirst(); + + Assert.NotNull(result); + Assert.Same(capability, result); + Assert.Equal("Required", result.Name); + } + + [Fact] + public void GetRequiredFirst_WithMultipleCapabilities_ReturnsFirstInOrder() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject) + .Add(new TestCapability("Z"), order: 30) + .Add(new TestCapability("A"), order: 10) + .Add(new TestCapability("M"), order: 20) + .Build(); + + var result = composition.GetRequiredFirst(); + + Assert.NotNull(result); + Assert.Equal("A", result.Name); + } + + [Fact] + public void GetRequiredFirst_WithDifferentTypes_ReturnsCorrectType() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject) + .Add(new TestCapability("TestCap")) + .Add(new DocumentCapability("DocType", "Content")) + .Build(); + + var testResult = composition.GetRequiredFirst(); + var docResult = composition.GetRequiredFirst(); + + Assert.NotNull(testResult); + Assert.Equal("TestCap", testResult.Name); + + Assert.NotNull(docResult); + Assert.Equal("DocType", docResult.Type); + } +} diff --git a/src/Cocoar.Capabilities.Tests/GetLastTests.cs b/src/Cocoar.Capabilities.Tests/GetLastTests.cs new file mode 100644 index 0000000..9da2555 --- /dev/null +++ b/src/Cocoar.Capabilities.Tests/GetLastTests.cs @@ -0,0 +1,251 @@ +using Xunit; + +namespace Cocoar.Capabilities.Tests; + +public class GetLastTests +{ + private record TestCapability(string Value); + private record OtherCapability(int Number); + + [Fact] + public void GetLastOrDefault_WithNoCapabilities_ReturnsNull() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject).Build(); + + var result = composition.GetLastOrDefault(); + + Assert.Null(result); + } + + [Fact] + public void GetLastOrDefault_WithOneCapability_ReturnsThatCapability() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var capability = new TestCapability("only"); + var composition = scope.For(subject) + .Add(capability) + .Build(); + + var result = composition.GetLastOrDefault(); + + Assert.Same(capability, result); + } + + [Fact] + public void GetLastOrDefault_WithMultipleCapabilities_ReturnsLastInOrder() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var first = new TestCapability("first"); + var second = new TestCapability("second"); + var third = new TestCapability("third"); + + var composition = scope.For(subject) + .Add(first) + .Add(second) + .Add(third) + .Build(); + + var result = composition.GetLastOrDefault(); + + Assert.Same(third, result); + Assert.Equal("third", result!.Value); + } + + [Fact] + public void GetLastOrDefault_WithDifferentTypes_ReturnsCorrectType() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var testCap = new TestCapability("test"); + var otherCap = new OtherCapability(42); + + var composition = scope.For(subject) + .Add(testCap) + .Add(otherCap) + .Build(); + + var result = composition.GetLastOrDefault(); + + Assert.Same(otherCap, result); + Assert.Equal(42, result!.Number); + } + + [Fact] + public void GetRequiredLast_WithNoCapabilities_ThrowsInvalidOperationException() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject).Build(); + + var ex = Assert.Throws( + () => composition.GetRequiredLast()); + + Assert.Contains("TestCapability", ex.Message); + Assert.Contains("not found", ex.Message); + } + + [Fact] + public void GetRequiredLast_WithOneCapability_ReturnsThatCapability() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var capability = new TestCapability("only"); + var composition = scope.For(subject) + .Add(capability) + .Build(); + + var result = composition.GetRequiredLast(); + + Assert.Same(capability, result); + } + + [Fact] + public void GetRequiredLast_WithMultipleCapabilities_ReturnsLastInOrder() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var first = new TestCapability("first"); + var second = new TestCapability("second"); + var third = new TestCapability("third"); + + var composition = scope.For(subject) + .Add(first) + .Add(second) + .Add(third) + .Build(); + + var result = composition.GetRequiredLast(); + + Assert.Same(third, result); + Assert.Equal("third", result.Value); + } + + [Fact] + public void GetRequiredLast_WithDifferentTypes_ReturnsCorrectType() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var testCap = new TestCapability("test"); + var otherCap = new OtherCapability(42); + + var composition = scope.For(subject) + .Add(testCap) + .Add(otherCap) + .Build(); + + var result = composition.GetRequiredLast(); + + Assert.Same(otherCap, result); + Assert.Equal(42, result.Number); + } + + [Fact] + public void TryGetLast_WithNoCapabilities_ReturnsFalse() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var composition = scope.For(subject).Build(); + + var result = composition.TryGetLast(out var capability); + + Assert.False(result); + Assert.Null(capability); + } + + [Fact] + public void TryGetLast_WithOneCapability_ReturnsTrueAndCapability() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var expected = new TestCapability("only"); + var composition = scope.For(subject) + .Add(expected) + .Build(); + + var result = composition.TryGetLast(out var capability); + + Assert.True(result); + Assert.Same(expected, capability); + } + + [Fact] + public void TryGetLast_WithMultipleCapabilities_ReturnsTrueAndLastCapability() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var first = new TestCapability("first"); + var second = new TestCapability("second"); + var third = new TestCapability("third"); + + var composition = scope.For(subject) + .Add(first) + .Add(second) + .Add(third) + .Build(); + + var result = composition.TryGetLast(out var capability); + + Assert.True(result); + Assert.Same(third, capability); + Assert.Equal("third", capability!.Value); + } + + [Fact] + public void TryGetLast_WithDifferentTypes_ReturnsCorrectType() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var testCap = new TestCapability("test"); + var otherCap = new OtherCapability(42); + + var composition = scope.For(subject) + .Add(testCap) + .Add(otherCap) + .Build(); + + var result = composition.TryGetLast(out var capability); + + Assert.True(result); + Assert.Same(otherCap, capability); + Assert.Equal(42, capability!.Number); + } + + [Fact] + public void GetLast_WithOrdering_RespectsOrderConfiguration() + { + using var scope = new CapabilityScope(TestOptions.Disabled); + var subject = new StringSubject("test"); + + var low = new TestCapability("low-priority"); + var medium = new TestCapability("medium-priority"); + var high = new TestCapability("high-priority"); + + var composition = scope.For(subject) + .Add(medium, order: 5) + .Add(high, order: 10) + .Add(low, order: 1) + .Build(); + + // Last should be the highest order value + var result = composition.GetLastOrDefault(); + + Assert.Same(high, result); + Assert.Equal("high-priority", result!.Value); + } +} diff --git a/src/Cocoar.Capabilities/Composition.cs b/src/Cocoar.Capabilities/Composition.cs index cda45f3..3747440 100644 --- a/src/Cocoar.Capabilities/Composition.cs +++ b/src/Cocoar.Capabilities/Composition.cs @@ -128,6 +128,78 @@ public IReadOnlyList GetAll() return list.AsReadOnly(); } + public TCapability? GetFirstOrDefault() + where TCapability : class + { + var queryType = typeof(TCapability); + if (!_capabilitiesByType.TryGetValue(queryType, out var arr) || arr.Length == 0) + { + return null; + } + return (TCapability)arr.GetValue(0)!; + } + + public TCapability GetRequiredFirst() + where TCapability : class + { + var queryType = typeof(TCapability); + if (!_capabilitiesByType.TryGetValue(queryType, out var arr) || arr.Length == 0) + { + throw new InvalidOperationException( + $"Capability of type '{typeof(TCapability).Name}' not found."); + } + return (TCapability)arr.GetValue(0)!; + } + + public bool TryGetFirst(out TCapability capability) + where TCapability : class + { + var queryType = typeof(TCapability); + if (_capabilitiesByType.TryGetValue(queryType, out var arr) && arr.Length > 0) + { + capability = (TCapability)arr.GetValue(0)!; + return true; + } + capability = null!; + return false; + } + + public TCapability? GetLastOrDefault() + where TCapability : class + { + var queryType = typeof(TCapability); + if (!_capabilitiesByType.TryGetValue(queryType, out var arr) || arr.Length == 0) + { + return null; + } + return (TCapability)arr.GetValue(arr.Length - 1)!; + } + + public TCapability GetRequiredLast() + where TCapability : class + { + var queryType = typeof(TCapability); + if (!_capabilitiesByType.TryGetValue(queryType, out var arr) || arr.Length == 0) + { + throw new InvalidOperationException( + $"Capability of type '{typeof(TCapability).Name}' not found."); + } + return (TCapability)arr.GetValue(arr.Length - 1)!; + } + + public bool TryGetLast(out TCapability capability) + where TCapability : class + { + var queryType = typeof(TCapability); + if (_capabilitiesByType.TryGetValue(queryType, out var arr) && arr.Length > 0) + { + capability = (TCapability)arr.GetValue(arr.Length - 1)!; + return true; + } + capability = null!; + return false; + } + public bool Has() where TCapability : class { diff --git a/src/Cocoar.Capabilities/IComposition.cs b/src/Cocoar.Capabilities/IComposition.cs index e6238ca..c76c144 100644 --- a/src/Cocoar.Capabilities/IComposition.cs +++ b/src/Cocoar.Capabilities/IComposition.cs @@ -25,6 +25,18 @@ public interface IComposition IReadOnlyList GetAll(); + TCapability? GetFirstOrDefault() where TCapability : class; + + TCapability GetRequiredFirst() where TCapability : class; + + bool TryGetFirst(out TCapability capability) where TCapability : class; + + TCapability? GetLastOrDefault() where TCapability : class; + + TCapability GetRequiredLast() where TCapability : class; + + bool TryGetLast(out TCapability capability) where TCapability : class; + bool Has() where TCapability : class; int Count() where TCapability : class;