diff --git a/CLAUDE.md b/CLAUDE.md index e3a6282..9e02f5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ The library splits extensions across three namespaces — callers must import th | `ValueExtensions.cs` | `CSharpHelperExtensions.Values` | `In`, `IsBetween`, `ToJson` | | `EnumerableExtensions.cs` | `CSharpHelperExtensions.Enumerable` | `IsNullOrEmpty`, `HasAny`, `None`, `CleanNullOrEmptyItems`, `WhereNotNull`, `ContainsOnly`, `AreEqual`, `ForEach`, `Reduce`, `SelectAsync`, `WhenAllList`, `Partition`, `Batch`, `MinByOrDefault`, `MaxByOrDefault`, `ToDictionarySafe`, `AddIf`, `AddRangeIf`, `ConcatIf`, `IsSingle`, `IndexOf`, `Yield`, `WithIndex`, `JoinAsString`, `AsReadOnlyList`, `ToHashSetSafe`, `OrEmpty` | | `StringExtensions.cs` | `CSharpHelperExtensions.Strings` | `IsNullOrEmpty`, `HasValue`, `OrEmpty`, `OrDefault`, `Truncate`, `Reverse`, `TrimToLower`, `TrimToUpper`, `ToTitleCase`, `ToSlug`, `MaskStart`, `EqualsIgnoreCase`, `ContainsIgnoreCase`, `StartsWithIgnoreCase`, `EndsWithIgnoreCase`, `EnsurePrefix`, `EnsureSuffix`, `TrimPrefix`, `TrimSuffix`, `SplitNonEmpty`, `JoinWith`, `ReplaceMany`, `RemoveWhitespace`, `CollapseWhitespace`, `RemoveDiacritics`, `IsNumeric`, `IsAlpha`, `IsAlphaNumeric`, `ToNullable`, `ToIntOrNull`, `ToDecimalOrNull`, `ToDateTimeOrNull`, `ToGuidOrNull`, `ToBoolOrNull`, `Base64Encode`, `Base64Decode`, `ToBase64Url`, `FromBase64Url`, `ToUtf8Bytes`, `ToUtf8Stream` | +| `DictionaryExtensions.cs` | `CSharpHelperExtensions.Dictionaries` | `GetValueOrDefault`, `GetOrAdd`, `Merge`, `AddRange`, `RemoveWhere`, `AsReadOnly` | `IsNullOrEmpty` exists in **both** `StringExtensions` (for `string`, namespace `CSharpHelperExtensions.Strings`) and `EnumerableExtensions` (for `IEnumerable`, namespace `CSharpHelperExtensions.Enumerable`). Be careful about which namespace is imported. diff --git a/Directory.Build.props b/Directory.Build.props index 74db21e..9f64ba9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,13 +11,13 @@ https://github.com/rbipin/dry-extensions-csharp https://github.com/rbipin/dry-extensions-csharp git - README.md + docs/README.md logo-nuget.png true - + diff --git a/README.md b/README.md index a0e8446..5e729e4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # CSharpHelperExtensions -A set of commonly used C# extension methods that reduce boilerplate across three focused namespaces: value checks, string manipulation, and collection operations. +A set of commonly used C# extension methods that reduce boilerplate across four focused namespaces: value checks, string manipulation, collection operations, and dictionary helpers. [![.NET](https://github.com/rbipin/dry-extensions-csharp/actions/workflows/dotnet.yml/badge.svg)](https://github.com/rbipin/dry-extensions-csharp/actions/workflows/dotnet.yml) @@ -21,6 +21,7 @@ Import the namespace for the extensions you need: | `CSharpHelperExtensions.Values` | `In`, `IsBetween`, `ToJson` | | `CSharpHelperExtensions.Strings` | All `string` extensions | | `CSharpHelperExtensions.Enumerable` | All `IEnumerable` and collection extensions | +| `CSharpHelperExtensions.Dictionaries` | All `IDictionary` extensions | ## Interactive Samples @@ -39,6 +40,7 @@ Each notebook loads the compiled DLL and imports the relevant namespace in its * | [`sample/value-extensions.ipynb`](sample/value-extensions.ipynb) | `CSharpHelperExtensions.Values` | `In`, `IsBetween` (all four `BetweenComparison` modes), `ToJson`, and chaining examples | | [`sample/string-extensions.ipynb`](sample/string-extensions.ipynb) | `CSharpHelperExtensions.Strings` | All 50+ string methods grouped by category: null-safety, parsing, transformation, whitespace, comparisons, prefix/suffix, encoding, and chaining pipelines | | [`sample/enumerable-extension.ipynb`](sample/enumerable-extension.ipynb) | `CSharpHelperExtensions.Enumerable` | All collection methods: presence checks, materialization, async projection, partitioning, batching, conditional mutation, and chaining pipelines | +| [`sample/dictionary-extensions.ipynb`](sample/dictionary-extensions.ipynb) | `CSharpHelperExtensions.Dictionaries` | All dictionary methods: safe lookup, add-if-missing, merging, bulk add, in-place filtering, read-only views, and chaining pipelines | ## Usage @@ -125,6 +127,30 @@ items.WithIndex(); // (Index, Item) tuples names.JoinAsString(", "); // fluent string.Join ``` +### Dictionaries + +```csharp +using CSharpHelperExtensions.Dictionaries; + +// Safe lookup — returns default instead of throwing on missing key or null dict +DictionaryExtensions.GetValueOrDefault(dict, "key"); // value or default(TValue) + +// Add-if-missing — factory only called when key is absent +cache.GetOrAdd("user:1", key => LoadFromDb(key)); // existing or newly stored value + +// Merge two dictionaries — overwrite:false keeps existing values (default) +defaults.Merge(overrides, overwrite: true); // returns same dict for chaining + +// Bulk add from any IEnumerable +inventory.AddRange(incomingItems); // returns same dict for chaining + +// Filter in-place by key predicate +config.RemoveWhere(k => k.StartsWith("internal.")); // returns same dict for chaining + +// Expose as a live read-only view +IReadOnlyDictionary view = DictionaryExtensions.AsReadOnly(dict); +``` + ## Building and Testing ```bash diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..85ee5d2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,144 @@ +# CSharpHelperExtensions + +A set of commonly used C# extension methods that reduce boilerplate across four focused namespaces: value checks, string manipulation, collection operations, and dictionary helpers. + +[![.NET](https://github.com/rbipin/dry-extensions-csharp/actions/workflows/dotnet.yml/badge.svg)](https://github.com/rbipin/dry-extensions-csharp/actions/workflows/dotnet.yml) + +## Installation + +```bash +dotnet add package CSharpHelperExtensions +``` + +## Namespaces + +Import the namespace for the extensions you need: + +| Namespace | What it covers | +|---|---| +| `CSharpHelperExtensions.Values` | `In`, `IsBetween`, `ToJson` | +| `CSharpHelperExtensions.Strings` | All `string` extensions | +| `CSharpHelperExtensions.Enumerable` | All `IEnumerable` and collection extensions | +| `CSharpHelperExtensions.Dictionaries` | All `IDictionary` extensions | + +## Interactive Samples + +The [`sample/`](https://github.com/rbipin/CSharpHelperExtensions/tree/main/sample) folder contains three [.NET Interactive](https://github.com/dotnet/interactive) notebooks you can run directly in VS Code (with the [Polyglot Notebooks](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) extension) or Jupyter. + +Each notebook loads the compiled DLL and imports the relevant namespace in its **Setup** cell — run that cell first, then run any section independently. + +| Notebook | Namespace | What it covers | +|---|---|---| +| [`sample/value-extensions.ipynb`](https://github.com/rbipin/CSharpHelperExtensions/tree/main/sample/value-extensions.ipynb) | `CSharpHelperExtensions.Values` | `In`, `IsBetween` (all four `BetweenComparison` modes), `ToJson`, and chaining examples | +| [`sample/string-extensions.ipynb`](https://github.com/rbipin/CSharpHelperExtensions/tree/main/sample/string-extensions.ipynb) | `CSharpHelperExtensions.Strings` | All 50+ string methods grouped by category: null-safety, parsing, transformation, whitespace, comparisons, prefix/suffix, encoding, and chaining pipelines | +| [`sample/enumerable-extension.ipynb`](https://github.com/rbipin/CSharpHelperExtensions/tree/main/sample/enumerable-extension.ipynb) | `CSharpHelperExtensions.Enumerable` | All collection methods: presence checks, materialization, async projection, partitioning, batching, conditional mutation, and chaining pipelines | +| [`sample/dictionary-extensions.ipynb`](https://github.com/rbipin/CSharpHelperExtensions/tree/main/sample/dictionary-extensions.ipynb) | `CSharpHelperExtensions.Dictionaries` | All dictionary methods: safe lookup, add-if-missing, merging, bulk add, in-place filtering, read-only views, and chaining pipelines | + +## Usage + +### Values + +```csharp +using CSharpHelperExtensions.Values; + +// Membership check — like SQL IN +"admin".In("admin", "superadmin"); // true +HttpMethod.Post.In(Post, Put, Patch); // true + +// Range check — inclusive by default +5.IsBetween(1, 10); // true +1.IsBetween(1, 10, BetweenComparison.ExcludeBoth); // false + +// JSON serialisation via Newtonsoft.Json +new { Name = "Alice", Age = 30 }.ToJson(); // {"Name":"Alice","Age":30} +new { Name = "Alice" }.ToJson(indentation: true); // pretty-printed +``` + +### Strings + +```csharp +using CSharpHelperExtensions.Strings; + +// Null-safety +" ".IsNullOrEmpty(); // true (checks whitespace) +"hello".HasValue(); // true +((string)null).OrDefault("N/A"); // "N/A" + +// Transformation +" Hello World ".TrimToLower(); // "hello world" +"café au lait".ToSlug(); // "cafe-au-lait" +"4111111111111234".MaskStart(4); // "************1234" + +// Safe parsing — returns null instead of throwing +"42".ToIntOrNull(); // 42 +"abc".ToIntOrNull(); // null + +// Comparisons +"Hello".EqualsIgnoreCase("HELLO"); // true +"path/".EnsurePrefix("/"); // "/path/" +"report.csv".TrimSuffix(".csv"); // "report" + +// Encoding +"Hello".Base64Encode(); // "SGVsbG8=" +"Hello".ToBase64Url(); // URL-safe, no padding chars +``` + +### Enumerable + +```csharp +using CSharpHelperExtensions.Enumerable; + +// Null-safe presence checks +list.HasAny(); // non-null and non-empty +list.None(); // null or empty +list.OrEmpty(); // null → empty sequence + +// Filtering +items.WhereNotNull(); // removes null elements +strings.CleanNullOrEmptyItems(); // removes null, empty, and whitespace strings + +// Async projection with optional concurrency cap +var results = await ids.SelectAsync(FetchAsync, maxParallel: 4); + +// Splitting +var (passed, failed) = scores.Partition(s => s >= 60); +var batches = items.Batch(100); // process in chunks + +// Conditional building — fluent, returns same list +var tags = new List() + .AddIf(isPremium, "premium") + .AddIf(isAdmin, "admin"); + +// Min/Max that return default instead of throwing on empty +people.MinByOrDefault(p => p.Age); +people.MaxByOrDefault(p => p.Age); + +// Utilities +42.Yield(); // wrap a single value as IEnumerable +items.WithIndex(); // (Index, Item) tuples +names.JoinAsString(", "); // fluent string.Join +``` + +### Dictionaries + +```csharp +using CSharpHelperExtensions.Dictionaries; + +// Safe lookup — returns default instead of throwing on missing key or null dict +DictionaryExtensions.GetValueOrDefault(dict, "key"); // value or default(TValue) + +// Add-if-missing — factory only called when key is absent +cache.GetOrAdd("user:1", key => LoadFromDb(key)); // existing or newly stored value + +// Merge two dictionaries — overwrite:false keeps existing values (default) +defaults.Merge(overrides, overwrite: true); // returns same dict for chaining + +// Bulk add from any IEnumerable +inventory.AddRange(incomingItems); // returns same dict for chaining + +// Filter in-place by key predicate +config.RemoveWhere(k => k.StartsWith("internal.")); // returns same dict for chaining + +// Expose as a live read-only view +IReadOnlyDictionary view = DictionaryExtensions.AsReadOnly(dict); +``` diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..4cfe500 --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,287 @@ +# Generic Extensions — Implementation Spec + +A complete list of extension methods to implement in `CSharpHelperExtensions`. All members are BCL-only with no domain dependencies. + +--- + +## Extension classes + +### `StringExtensions` (`CSharpHelperExtensions.Strings`) + +| Member | Purpose | +|---|---| +| `HasValue` | true when not null/whitespace | +| `OrEmpty` | null → `string.Empty` | +| `OrDefault(fallback)` | null/whitespace → fallback | +| `Truncate(maxLength)` | clip to length | +| `TrimToLower` / `TrimToUpper` | trim + case in one call | +| `MaskStart(visibleCount, maskChar)` | `****56` style masking | +| `ToIntOrNull` / `ToDecimalOrNull` / `ToDateTimeOrNull` / `ToGuidOrNull` / `ToBoolOrNull` | typed parse-or-null helpers | +| `Base64Encode` / `Base64Decode` | standard Base64 round-trip | +| `ToBase64Url` / `FromBase64Url` | URL-safe Base64 (no `+/=`) | +| `ToUtf8Bytes` | `Encoding.UTF8.GetBytes` wrapper | +| `ToUtf8Stream` | string → `MemoryStream` | +| `JoinWith(values)` | `string.Join` with fluent receiver | +| `EqualsIgnoreCase` / `ContainsIgnoreCase` / `StartsWithIgnoreCase` / `EndsWithIgnoreCase` | ordinal ignore-case comparisons | +| `RemoveWhitespace` | strip all whitespace | +| `CollapseWhitespace` | runs of spaces → single space | +| `ReplaceMany(pairs)` | chain multiple replacements | +| `RemoveDiacritics` | strip accent marks | +| `ToSlug` | lowercase, diacritics-free, dash-separated | +| `IsNumeric` / `IsAlpha` / `IsAlphaNumeric` | char-set predicates | +| `Reverse` | reverse character order | +| `SplitNonEmpty(separators)` | split + remove empty entries | +| `EnsurePrefix(prefix)` / `EnsureSuffix(suffix)` | add if missing | +| `TrimPrefix(prefix)` / `TrimSuffix(suffix)` | remove if present | + +--- + +### `EnumerableExtensions` (`CSharpHelperExtensions.Enumerable`) + +| Member | Purpose | +|---|---| +| `HasAny` | non-null + non-empty check | +| `OrEmpty` | null → empty sequence | +| `AsReadOnlyList` | materialize as `IReadOnlyList` | +| `WhereNotNull` | filter out nulls | +| `JoinAsString(separator)` | `string.Join` on a sequence | +| `ToDictionarySafe(key, value)` | `ToDictionary` that handles duplicate keys | +| `AddIf(condition, item)` | conditional `Add` returning the list | +| `AddRangeIf(condition, items)` | conditional `AddRange` returning the list | +| `None(predicate?)` | `!Any(...)` with clearer intent | +| `IsSingle` / `IsSingle(predicate)` | exactly one element, allocation-safe | +| `IndexOf(predicate)` | first matching index or -1 | +| `WithIndex` | `(index, item)` tuples | +| `Partition(predicate)` | split into matched + rest in one pass | +| `Batch(size)` | null-safe chunking | +| `MinByOrDefault(keySelector)` / `MaxByOrDefault` | `MinBy`/`MaxBy` that return default on empty | +| `ConcatIf(condition, other)` | conditional concat | +| `Yield` | wrap a single item as a sequence | +| `ToHashSetSafe` | null-safe `ToHashSet` | +| `SelectAsync(selector, maxParallel?)` | concurrent projection with optional parallelism cap | +| `WhenAllList` | `Task.WhenAll` returning `IReadOnlyList` | + +--- + +### `DictionaryExtensions` (`CSharpHelperExtensions.Enumerable`) + +| Member | Purpose | +|---|---| +| `GetOrAdd(key, factory)` | add-if-missing, return value | +| `GetValueOrDefault(key)` | null-safe value lookup | +| `Merge(other, overwrite?)` | combine two dictionaries | +| `AddRange(pairs, overwrite?)` | bulk add | +| `RemoveWhere(predicate)` | filter-in-place | +| `AsReadOnly` | wrap as `IReadOnlyDictionary` | + +--- + +### `ObjectExtensions` (`CSharpHelperExtensions`) + +| Member | Purpose | +|---|---| +| `Pipe(transform)` | `x.Pipe(f)` → `f(x)` | +| `Tap(action)` | run side-effect, return self | +| `When(condition, transform)` | conditional transform | +| `WhenNotNull(transform)` | transform only when not null | +| `In(values)` | `x.In(a,b,c)` membership check | +| `ThrowIfNull(paramName?)` | throw `ArgumentNullException` inline | +| `As` | safe cast (`x as T`) | +| `Is` | type check (`x is T`) | + +--- + +### `GuardExtensions` (`CSharpHelperExtensions`) + +| Member | Purpose | +|---|---| +| `ThrowIfNull(paramName?)` | `ArgumentNullException` | +| `ThrowIfEmpty(paramName?)` | `ArgumentException` for empty strings/collections | +| `ThrowIfWhitespace(paramName?)` | `ArgumentException` for whitespace strings | +| `ThrowIf(condition, message)` | general precondition check | + +--- + +### `NullableExtensions` (`CSharpHelperExtensions`) + +| Member | Purpose | +|---|---| +| `OrDefault(fallback)` | `T?` → `T` with fallback | +| `Map(transform)` | apply transform when has value | +| `IsZeroOrNull` | true when null or 0 (for numeric Nullable types) | + +--- + +### `BooleanExtensions` (`CSharpHelperExtensions`) + +| Member | Purpose | +|---|---| +| `ToYesNo` / `ToYN` / `ToOneZero` | format for reports | +| `Then(ifTrue, ifFalse)` | inline ternary as method | +| `AsBoolean()` | to convert int, small int, 'y', 'n', 'true', 'false', 'yes', 'no' to boolean true or false | + +--- + +### `DecimalExtensions` (`CSharpHelperExtensions`) + +| Member | Purpose | +|---|---| +| `RoundToHalf` | round to nearest 0.5 | +| `RoundCurrency` | round to 2 decimal places (MidpointRounding.AwayFromZero) | +| `IsZero` / `IsPositive` / `IsNegative` | sign predicates | + +--- + +### `GuidExtensions` (`CSharpHelperExtensions`) + +| Member | Purpose | +|---|---| +| `IsEmpty` | `g == Guid.Empty` | +| `OrNew` | return `Guid.NewGuid()` when empty | +| `ToShortString` | URL-safe base64 form (22 chars) | + +--- + +### `DateTimeExtensions` (`CSharpHelperExtensions.Dates`) + +| Member | Purpose | +|---|---| +| `StartOfDay` / `EndOfDay` | midnight / 23:59:59.999 | +| `StartOfWeek(firstDay?)` / `EndOfWeek` | week boundaries | +| `StartOfMonth` / `EndOfMonth` | month boundaries | +| `IsBetween(start, end)` | inclusive range check | +| `ToIsoString` | ISO 8601 (`yyyy-MM-ddTHH:mm:ss`) | +| `ToShortIso` | date-only ISO (`yyyy-MM-dd`) | +| `ToDateOnly` | convert to `DateOnly` | +| `ToUnixTimestamp` / `ToUnixTimestampMs` | seconds / milliseconds since epoch | +| `Age(on?)` | years elapsed with rollover | +| `IsWeekend` / `IsWeekday` | day-of-week predicates | +| `NextBusinessDay` / `AddBusinessDays(n)` | Mon-Fri arithmetic (no holiday awareness) | +| `ClampTo(min, max)` | constrain to range | +| `ToRelativeString(now?)` | "3 minutes ago" / "in 2 days" | +| `TimeSince` / `TimeUntil` | `UtcNow - x` / `x - UtcNow` | +| `EachDayUntil(end)` | enumerate days in a range | + +--- + +### `DateOnlyExtensions` (`CSharpHelperExtensions.Dates`) + +| Member | Purpose | +|---|---| +| `StartOfWeek(firstDay?)` / `EndOfWeek` | week boundaries | +| `StartOfMonth` / `EndOfMonth` | month boundaries | +| `IsBetween(start, end)` | inclusive range check | +| `ToIsoString` | `yyyy-MM-dd` | +| `ToDateTime(time?)` | convert to `DateTime` | +| `EachDayUntil(end)` | enumerate days in a range | + +--- + +### `EnumExtensions` (`CSharpHelperExtensions.Enums`) + +| Member | Purpose | +|---|---| +| `GetDescription` | read `[Description]` attribute | +| `ToEnum(fallback?)` | string → enum | +| `TryToEnum(out value)` | safe string → enum | +| `All` | all defined values | +| `IsDefinedValue` | `Enum.IsDefined` wrapper | + +--- + +### `TaskExtensions` (`CSharpHelperExtensions.Tasks`) + +| Member | Purpose | +|---|---| +| `WithTimeout(timeout)` | throw `TimeoutException` if not done in time | +| `WithCancellation(token)` | make non-cancellable task respect a token | +| `FireAndForget(onError?)` | discard task, log exceptions safely | +| `OrDefault(fallback)` | return fallback on any exception | +| `Then(transform)` | sync continuation | +| `Then(asyncTransform)` | async continuation | +| `NoSync` | shorthand for `.ConfigureAwait(false)` | + +--- + +### `ExceptionExtensions` (`CSharpHelperExtensions.Reflection`) + +| Member | Purpose | +|---|---| +| `GetFullMessage` | join full inner-exception chain with ` → ` | +| `IsTransient` | matches well-known retryable patterns (SocketException, TimeoutException, transient HTTP) | +| `Flatten` | unroll `AggregateException` + inner chain | + +--- + +### `TypeExtensions` (`CSharpHelperExtensions.Reflection`) + +| Member | Purpose | +|---|---| +| `GetFriendlyName` | readable generic type name (e.g. `Dictionary`) | +| `IsNullableValueType` | `Nullable.GetUnderlyingType(t) != null` | +| `ImplementsGeneric(openGeneric)` | e.g. `typeof(List).ImplementsGeneric(typeof(IEnumerable<>))` | +| `GetAttribute` | sugar over `GetCustomAttribute()` | + +--- + +### `StreamExtensions` (`CSharpHelperExtensions.Text`) + +| Member | Purpose | +|---|---| +| `ToUtf8MemoryStream` | string → seekable `MemoryStream` | +| `ReadAsStringAsync(token?)` | stream → string | +| `ToByteArrayAsync(token?)` | stream → `byte[]` | +| `ReadAsJsonAsync(options?, token?)` | deserialize stream as JSON | +| `WriteAsJsonAsync(value, options?, token?)` | serialize to stream as JSON | + +--- + +### `JsonExtensions` (`CSharpHelperExtensions.Text`) + +Uses `System.Text.Json`. + +| Member | Purpose | +|---|---| +| `ToJson(options?)` | serialize to JSON string | +| `FromJson(options?)` | deserialize from JSON string | +| `TryFromJson(out value, options?)` | safe deserialize | +| `WithCamelCase(options)` | add camelCase naming policy | +| `WithEnumStrings(options)` | add string enum converter | +| `WithPrettyPrint(options)` | enable indented output | + +--- + +### `RandomExtensions` (`CSharpHelperExtensions`) + +| Member | Purpose | +|---|---| +| `NextOf(list)` | pick a random element from a list | +| `Shuffle(source, random?)` | Fisher-Yates shuffle | + +--- + +### `HttpResponseMessageExtensions` (`CSharpHelperExtensions.Net`) + +| Member | Purpose | +|---|---| +| `EnsureSuccessAndReadAsStringAsync` | `EnsureSuccessStatusCode` + `ReadAsStringAsync` | +| `ReadJsonAsync(options?)` | read and deserialize response body as JSON | + +--- + +## Implementation phases + +| Phase | Scope | +|---|---| +| **P1** | `StringExtensions`, `EnumerableExtensions`, `ObjectExtensions`, `GuardExtensions`, `TaskExtensions`, `ExceptionExtensions` | +| **P2** | `DictionaryExtensions`, `NullableExtensions`, `BooleanExtensions`, `DecimalExtensions`, `GuidExtensions`, `DateTimeExtensions`, `DateOnlyExtensions`, `EnumExtensions` | +| **P3** | `StreamExtensions`, `JsonExtensions`, `TypeExtensions`, `RandomExtensions`, `HttpResponseMessageExtensions` | + +--- + +## Open questions + +1. **NuGet visibility** — publish to nuget.org under an OSS license, or keep on a private feed? +2. **Async-only vs sync overloads** — for `TaskExtensions`, `StreamExtensions`, `HttpResponseMessageExtensions`: async-only, or include sync where BCL supports it? +3. **`Pipe`/`Tap` naming** — ship as-is, or also add `Let`/`Also` aliases (Kotlin-style)? Aliases increase IntelliSense noise. +4. **JSON library** — `JsonExtensions` targets `System.Text.Json`; existing `ToJson` in `GenericExtensions` uses Newtonsoft.Json. Migrate in a breaking v3.0, or keep both? diff --git a/docs/superpowers/plans/2026-05-30-dictionary-extensions.md b/docs/superpowers/plans/2026-05-30-dictionary-extensions.md new file mode 100644 index 0000000..5c72037 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-dictionary-extensions.md @@ -0,0 +1,554 @@ +# DictionaryExtensions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `DictionaryExtensions` static class with five fluent extension methods for `IDictionary` in a new `CSharpHelperExtensions.Dictionaries` namespace. + +**Architecture:** Two new files following the existing pattern: one implementation file alongside `EnumerableExtensions.cs` and `StringExtensions.cs`, and one test file alongside their test counterparts. TDD throughout — write the failing test first, implement minimally, confirm green, commit. + +**Tech Stack:** C# / .NET 10, xUnit, Shouldly, `System.Collections.ObjectModel.ReadOnlyDictionary` + +--- + +## File Map + +| Action | Path | +|---|---| +| Create | `src/CSharpHelperExtensions/DictionaryExtensions.cs` | +| Create | `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` | + +--- + +### Task 1: Scaffold the files + +**Files:** +- Create: `src/CSharpHelperExtensions/DictionaryExtensions.cs` +- Create: `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` + +- [ ] **Step 1: Create the implementation stub** + +Create `src/CSharpHelperExtensions/DictionaryExtensions.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +#nullable enable +namespace CSharpHelperExtensions.Dictionaries; + +public static class DictionaryExtensions +{ +} +``` + +- [ ] **Step 2: Create the test file stub** + +Create `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs`: + +```csharp +using System.Collections.Generic; +using CSharpHelperExtensions.Dictionaries; +using Shouldly; +using Xunit; + +namespace CSharpHelperExtensions.Test; + +public class DictionaryExtensionTest +{ +} +``` + +- [ ] **Step 3: Verify the solution builds** + +Run: +```bash +dotnet build +``` +Expected: Build succeeded with 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +git commit -m "scaffold DictionaryExtensions and test files" +``` + +--- + +### Task 2: `GetOrAdd` + +**Files:** +- Modify: `src/CSharpHelperExtensions/DictionaryExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `DictionaryExtensionTest`: + +```csharp +[Fact] +public void GetOrAdd_KeyExists_ReturnsExistingValue() +{ + var dict = new Dictionary { ["a"] = 1 }; + var factoryCalled = false; + var result = dict.GetOrAdd("a", _ => { factoryCalled = true; return 99; }); + result.ShouldBe(1); + factoryCalled.ShouldBeFalse(); +} + +[Fact] +public void GetOrAdd_KeyMissing_InvokesFactoryAddsAndReturnsValue() +{ + var dict = new Dictionary(); + var result = dict.GetOrAdd("b", key => 42); + result.ShouldBe(42); + dict["b"].ShouldBe(42); +} + +[Fact] +public void GetOrAdd_NullFactory_Throws() +{ + var dict = new Dictionary(); + Should.Throw(() => dict.GetOrAdd("a", null!)); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~GetOrAdd" +``` +Expected: FAIL — `GetOrAdd` not found. + +- [ ] **Step 3: Implement `GetOrAdd`** + +Add to `DictionaryExtensions`: + +```csharp +public static TValue GetOrAdd( + this IDictionary dict, + TKey key, + Func factory) +{ + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(factory); + + if (dict.TryGetValue(key, out var existing)) + return existing; + + var value = factory(key); + dict[key] = value; + return value; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~GetOrAdd" +``` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +git commit -m "add GetOrAdd to DictionaryExtensions" +``` + +--- + +### Task 3: `Merge` + +**Files:** +- Modify: `src/CSharpHelperExtensions/DictionaryExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `DictionaryExtensionTest`: + +```csharp +[Fact] +public void Merge_NoOverlap_AddsAllEntries() +{ + var dict = new Dictionary { ["a"] = 1 }; + var other = new Dictionary { ["b"] = 2 }; + var result = dict.Merge(other); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(2); + dict["b"].ShouldBe(2); +} + +[Fact] +public void Merge_DuplicateKey_OverwriteFalse_KeepsExisting() +{ + var dict = new Dictionary { ["a"] = 1 }; + var other = new Dictionary { ["a"] = 99 }; + dict.Merge(other, overwrite: false); + dict["a"].ShouldBe(1); +} + +[Fact] +public void Merge_DuplicateKey_OverwriteTrue_ReplacesExisting() +{ + var dict = new Dictionary { ["a"] = 1 }; + var other = new Dictionary { ["a"] = 99 }; + dict.Merge(other, overwrite: true); + dict["a"].ShouldBe(99); +} + +[Fact] +public void Merge_NullOther_NoOp() +{ + var dict = new Dictionary { ["a"] = 1 }; + var result = dict.Merge(null!); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(1); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~Merge" +``` +Expected: FAIL — `Merge` not found. + +- [ ] **Step 3: Implement `Merge`** + +Add to `DictionaryExtensions`: + +```csharp +public static IDictionary Merge( + this IDictionary dict, + IDictionary? other, + bool overwrite = false) +{ + if (other is null) + return dict; + + foreach (var pair in other) + { + if (overwrite || !dict.ContainsKey(pair.Key)) + dict[pair.Key] = pair.Value; + } + + return dict; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~Merge" +``` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +git commit -m "add Merge to DictionaryExtensions" +``` + +--- + +### Task 4: `AddRange` + +**Files:** +- Modify: `src/CSharpHelperExtensions/DictionaryExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `DictionaryExtensionTest`: + +```csharp +[Fact] +public void AddRange_NoOverlap_AddsAllPairs() +{ + var dict = new Dictionary { ["a"] = 1 }; + var pairs = new List> { new("b", 2), new("c", 3) }; + var result = dict.AddRange(pairs); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(3); +} + +[Fact] +public void AddRange_DuplicateKey_OverwriteFalse_KeepsExisting() +{ + var dict = new Dictionary { ["a"] = 1 }; + var pairs = new List> { new("a", 99) }; + dict.AddRange(pairs, overwrite: false); + dict["a"].ShouldBe(1); +} + +[Fact] +public void AddRange_DuplicateKey_OverwriteTrue_ReplacesExisting() +{ + var dict = new Dictionary { ["a"] = 1 }; + var pairs = new List> { new("a", 99) }; + dict.AddRange(pairs, overwrite: true); + dict["a"].ShouldBe(99); +} + +[Fact] +public void AddRange_NullPairs_NoOp() +{ + var dict = new Dictionary { ["a"] = 1 }; + var result = dict.AddRange(null!); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(1); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~AddRange" +``` +Expected: FAIL — `AddRange` not found. + +- [ ] **Step 3: Implement `AddRange`** + +Add to `DictionaryExtensions`: + +```csharp +public static IDictionary AddRange( + this IDictionary dict, + IEnumerable>? pairs, + bool overwrite = false) +{ + if (pairs is null) + return dict; + + foreach (var pair in pairs) + { + if (overwrite || !dict.ContainsKey(pair.Key)) + dict[pair.Key] = pair.Value; + } + + return dict; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~AddRange" +``` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +git commit -m "add AddRange to DictionaryExtensions" +``` + +--- + +### Task 5: `RemoveWhere` + +**Files:** +- Modify: `src/CSharpHelperExtensions/DictionaryExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `DictionaryExtensionTest`: + +```csharp +[Fact] +public void RemoveWhere_RemovesMatchingKeys() +{ + var dict = new Dictionary { ["a"] = 1, ["b"] = 2, ["c"] = 3 }; + var result = dict.RemoveWhere(k => k == "a" || k == "c"); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(1); + dict.ContainsKey("b").ShouldBeTrue(); +} + +[Fact] +public void RemoveWhere_NoMatch_LeavesAllEntries() +{ + var dict = new Dictionary { ["a"] = 1, ["b"] = 2 }; + dict.RemoveWhere(k => k == "z"); + dict.Count.ShouldBe(2); +} + +[Fact] +public void RemoveWhere_EmptyDict_NoOp() +{ + var dict = new Dictionary(); + dict.RemoveWhere(k => true); + dict.Count.ShouldBe(0); +} + +[Fact] +public void RemoveWhere_NullPredicate_Throws() +{ + var dict = new Dictionary { ["a"] = 1 }; + Should.Throw(() => dict.RemoveWhere(null!)); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~RemoveWhere" +``` +Expected: FAIL — `RemoveWhere` not found. + +- [ ] **Step 3: Implement `RemoveWhere`** + +Add to `DictionaryExtensions`: + +```csharp +public static IDictionary RemoveWhere( + this IDictionary dict, + Func predicate) +{ + ArgumentNullException.ThrowIfNull(predicate); + + var keysToRemove = new List(); + foreach (var key in dict.Keys) + { + if (predicate(key)) + keysToRemove.Add(key); + } + + foreach (var key in keysToRemove) + dict.Remove(key); + + return dict; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~RemoveWhere" +``` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +git commit -m "add RemoveWhere to DictionaryExtensions" +``` + +--- + +### Task 6: `AsReadOnly` + +**Files:** +- Modify: `src/CSharpHelperExtensions/DictionaryExtensions.cs` +- Modify: `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `DictionaryExtensionTest`: + +```csharp +[Fact] +public void AsReadOnly_ReturnsIReadOnlyDictionary() +{ + var dict = new Dictionary { ["a"] = 1, ["b"] = 2 }; + IReadOnlyDictionary readOnly = dict.AsReadOnly(); + readOnly.Count.ShouldBe(2); + readOnly["a"].ShouldBe(1); +} + +[Fact] +public void AsReadOnly_ReflectsMutationsToUnderlying() +{ + var dict = new Dictionary { ["a"] = 1 }; + var readOnly = dict.AsReadOnly(); + dict["b"] = 2; + readOnly.Count.ShouldBe(2); + readOnly["b"].ShouldBe(2); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~AsReadOnly" +``` +Expected: FAIL — `AsReadOnly` not found. + +- [ ] **Step 3: Implement `AsReadOnly`** + +Add to `DictionaryExtensions`: + +```csharp +public static IReadOnlyDictionary AsReadOnly( + this IDictionary dict) => + new ReadOnlyDictionary(dict); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +dotnet test --filter "FullyQualifiedName~AsReadOnly" +``` +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +git commit -m "add AsReadOnly to DictionaryExtensions" +``` + +--- + +### Task 7: Full test run and cleanup + +**Files:** +- No new files + +- [ ] **Step 1: Run the full test suite** + +Run: +```bash +dotnet test --verbosity normal +``` +Expected: All tests pass, 0 failures. + +- [ ] **Step 2: Run the formatter** + +Run: +```bash +dotnet csharpier src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +``` +Expected: Files formatted with no errors. + +- [ ] **Step 3: Commit if formatter made changes** + +```bash +git add src/CSharpHelperExtensions/DictionaryExtensions.cs src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs +git commit -m "apply csharpier formatting to DictionaryExtensions" +``` + +- [ ] **Step 4: Verify the build is clean** + +Run: +```bash +dotnet build +``` +Expected: Build succeeded, 0 errors, 0 warnings. diff --git a/docs/superpowers/specs/2026-05-30-dictionary-extensions-design.md b/docs/superpowers/specs/2026-05-30-dictionary-extensions-design.md new file mode 100644 index 0000000..9a380d0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-dictionary-extensions-design.md @@ -0,0 +1,134 @@ +# DictionaryExtensions — Design Spec + +**Date:** 2026-05-30 +**Phase:** P2 (per `docs/plan.md`) +**Status:** Approved + +--- + +## Overview + +Add a `DictionaryExtensions` static class providing five fluent extension methods for `IDictionary`. These fill gaps in the BCL for common dictionary operations: conditional add, bulk add, merging, key-based filtering, and read-only wrapping. + +--- + +## File Layout + +| Path | Purpose | +|---|---| +| `src/CSharpHelperExtensions/DictionaryExtensions.cs` | Implementation | +| `src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs` | xUnit tests using Shouldly | + +--- + +## Namespace + +`CSharpHelperExtensions.Dictionaries` + +Callers import with: +```csharp +using CSharpHelperExtensions.Dictionaries; +``` + +--- + +## Methods + +### `GetOrAdd` + +```csharp +public static TValue GetOrAdd( + this IDictionary dict, + TKey key, + Func factory) +``` + +If `key` is already present, returns the existing value. Otherwise invokes `factory(key)`, adds the result, and returns it. + +**Throws:** `ArgumentNullException` if `key` or `factory` is null. + +--- + +### `Merge` + +```csharp +public static IDictionary Merge( + this IDictionary dict, + IDictionary other, + bool overwrite = false) +``` + +Adds all entries from `other` into `dict`. When `overwrite` is `false` (default), duplicate keys are skipped. When `true`, existing values are overwritten. Returns `dict` for fluent chaining. + +**Null behaviour:** silently no-ops if `other` is null. + +--- + +### `AddRange` + +```csharp +public static IDictionary AddRange( + this IDictionary dict, + IEnumerable> pairs, + bool overwrite = false) +``` + +Bulk-adds `pairs` into `dict`. Duplicate-key behaviour matches `Merge`: skip by default, overwrite when `true`. Returns `dict` for fluent chaining. + +**Null behaviour:** silently no-ops if `pairs` is null. + +--- + +### `RemoveWhere` + +```csharp +public static IDictionary RemoveWhere( + this IDictionary dict, + Func predicate) +``` + +Removes all entries whose key satisfies `predicate`. Mutates `dict` in-place. Returns `dict` for fluent chaining. + +**Throws:** `ArgumentNullException` if `predicate` is null. + +--- + +### `AsReadOnly` + +```csharp +public static IReadOnlyDictionary AsReadOnly( + this IDictionary dict) +``` + +Wraps `dict` in a `ReadOnlyDictionary`. The wrapper reflects subsequent mutations to the underlying dict (it is a live view, not a copy). + +--- + +## Error Handling Summary + +| Method | Null `dict` | Other null args | +|---|---|---| +| `GetOrAdd` | — (C# extension, caller handles) | `ArgumentNullException` for `key` or `factory` | +| `Merge` | — | silently no-op if `other` is null | +| `AddRange` | — | silently no-op if `pairs` is null | +| `RemoveWhere` | — | `ArgumentNullException` for `predicate` | +| `AsReadOnly` | — | no other args | + +--- + +## Testing + +One `[Fact]` per behaviour per method. Coverage targets: + +- `GetOrAdd`: key exists (returns existing, factory not called), key missing (factory called, value added and returned), null key throws, null factory throws +- `Merge`: no overlap (all added), overlap with `overwrite=false` (existing kept), overlap with `overwrite=true` (existing replaced), null `other` (no-op), fluent return is same instance +- `AddRange`: same cases as `Merge` using a list of `KeyValuePair`s, null `pairs` no-op +- `RemoveWhere`: keys matching predicate removed, non-matching keys kept, empty dict, null predicate throws, fluent return is same instance +- `AsReadOnly`: returned value is `IReadOnlyDictionary`, reflects mutations to underlying dict + +--- + +## Out of Scope + +- `GetValueOrDefault` — skipped; BCL already provides this on `Dictionary` (.NET 2.0+) +- Thread safety — no concurrent dictionary support; callers use `ConcurrentDictionary` directly diff --git a/sample/dictionary-extensions.ipynb b/sample/dictionary-extensions.ipynb new file mode 100644 index 0000000..cf4640e --- /dev/null +++ b/sample/dictionary-extensions.ipynb @@ -0,0 +1,812 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Dictionary Extensions\n", + "\n", + "Runnable samples for every method in `CSharpHelperExtensions.Dictionaries`. \n", + "Run the **Setup** cell first, then any section independently.\n", + "\n", + "| Section | Methods |\n", + "|---|---|\n", + "| [1. Safe Value Lookup](#1-safe-value-lookup) | `GetValueOrDefault` |\n", + "| [2. Add-If-Missing](#2-add-if-missing) | `GetOrAdd` |\n", + "| [3. Merging Dictionaries](#3-merging-dictionaries) | `Merge` |\n", + "| [4. Bulk Add](#4-bulk-add) | `AddRange` |\n", + "| [5. Filtering In-Place](#5-filtering-in-place) | `RemoveWhere` |\n", + "| [6. Read-Only View](#6-read-only-view) | `AsReadOnly` |\n", + "| [7. Chaining Examples](#7-chaining-examples) | Composing multiple extensions into pipelines |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "> **Run this cell first.** It loads the compiled library and imports the required namespaces.\n", + ">\n", + "> Build first if the DLL is missing: `dotnet build` from the repo root." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "#r \"../src/CSharpHelperExtensions/bin/Debug/net10.0/CSharpHelperExtensions.dll\"\n", + "using System.Collections.Generic;\n", + "using CSharpHelperExtensions.Dictionaries; // all DictionaryExtensions" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 1. Safe Value Lookup\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `GetValueOrDefault` | `IDictionary?, TKey → TValue?` | value for key, or `default` when dict/key is null or key not found |\n", + "\n", + "> **Note:** `GetValueOrDefault` is called using static syntax (`DictionaryExtensions.GetValueOrDefault(dict, key)`) rather than extension syntax (`dict.GetValueOrDefault(key)`). This is required because the BCL ships `CollectionExtensions.GetValueOrDefault` on `IReadOnlyDictionary` — since `Dictionary` implements both interfaces, the extension call is ambiguous and won't compile." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// GetValueOrDefault — returns value when key exists\n", + "var scores = new Dictionary\n", + "{\n", + " [\"alice\"] = 95,\n", + " [\"bob\"] = 82,\n", + "};\n", + "\n", + "display(DictionaryExtensions.GetValueOrDefault(scores, \"alice\")); // 95\n", + "display(DictionaryExtensions.GetValueOrDefault(scores, \"bob\")); // 82" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// GetValueOrDefault — returns default(TValue) when key is not found\n", + "display(DictionaryExtensions.GetValueOrDefault(scores, \"charlie\")); // 0 (default for int)\n", + "\n", + "var labels = new Dictionary { [\"en\"] = \"Hello\" };\n", + "display(DictionaryExtensions.GetValueOrDefault(labels, \"fr\")); // null (default for string)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// GetValueOrDefault — null-safe: returns default when dict is null\n", + "IDictionary? nullDict = null;\n", + "display(DictionaryExtensions.GetValueOrDefault(nullDict, \"any\")); // 0 (no NullReferenceException)\n", + "\n", + "// Also null-safe on key\n", + "display(DictionaryExtensions.GetValueOrDefault(scores, null!)); // 0 (null key → default)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 2. Add-If-Missing\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `GetOrAdd` | `IDictionary, TKey, Func → TValue` | existing value if key present; otherwise invokes factory, stores result, and returns it |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// GetOrAdd — returns existing value; factory is NOT called when key already exists\n", + "var cache = new Dictionary { [\"user:1\"] = \"Alice\" };\n", + "\n", + "var factoryCalled = false;\n", + "var result = cache.GetOrAdd(\"user:1\", key =>\n", + "{\n", + " factoryCalled = true;\n", + " return $\"fetched-{key}\";\n", + "});\n", + "\n", + "display(result); // \"Alice\" (existing value returned)\n", + "display(factoryCalled); // False (factory skipped)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// GetOrAdd — key missing: factory is called, result stored and returned\n", + "var result2 = cache.GetOrAdd(\"user:2\", key => $\"fetched-{key}\");\n", + "\n", + "display(result2); // \"fetched-user:2\"\n", + "display(cache[\"user:2\"]); // \"fetched-user:2\" (stored in dictionary)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// GetOrAdd — practical use: lazy-initialise nested collections\n", + "var grouped = new Dictionary>();\n", + "\n", + "void Add(string group, int value)\n", + " => grouped.GetOrAdd(group, _ => new List()).Add(value);\n", + "\n", + "Add(\"evens\", 2); Add(\"odds\", 1);\n", + "Add(\"evens\", 4); Add(\"odds\", 3);\n", + "Add(\"evens\", 6);\n", + "\n", + "display(grouped[\"evens\"]); // [2, 4, 6]\n", + "display(grouped[\"odds\"]); // [1, 3]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// GetOrAdd — throws ArgumentNullException for null key or null factory\n", + "var d = new Dictionary();\n", + "\n", + "try { d.GetOrAdd(null!, _ => 1); }\n", + "catch (ArgumentNullException e) { display($\"null key: {e.ParamName}\"); } // null key: key\n", + "\n", + "try { d.GetOrAdd(\"k\", null!); }\n", + "catch (ArgumentNullException e) { display($\"null factory: {e.ParamName}\"); } // null factory: factory" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 3. Merging Dictionaries\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `Merge` | `IDictionary, IDictionary?, bool overwrite=false → IDictionary` | original dict (mutated in-place) with all entries from `other` added |\n", + "\n", + "Returns the original dictionary for fluent chaining. A null `other` is silently ignored." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Merge — no overlapping keys: all entries from other are added\n", + "var defaults = new Dictionary { [\"timeout\"] = 30, [\"retries\"] = 3 };\n", + "var overrides = new Dictionary { [\"pageSize\"] = 50 };\n", + "\n", + "defaults.Merge(overrides);\n", + "display(defaults); // { timeout: 30, retries: 3, pageSize: 50 }" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Merge — overwrite: false (default) skips duplicate keys; existing values are kept\n", + "var config = new Dictionary { [\"theme\"] = \"light\", [\"lang\"] = \"en\" };\n", + "var userPrefs = new Dictionary { [\"theme\"] = \"dark\", [\"fontSize\"] = \"14\" };\n", + "\n", + "config.Merge(userPrefs, overwrite: false);\n", + "display(config[\"theme\"]); // \"light\" (existing value kept)\n", + "display(config[\"fontSize\"]); // \"14\" (new key added)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Merge — overwrite: true replaces existing values with values from other\n", + "var base1 = new Dictionary { [\"theme\"] = \"light\", [\"lang\"] = \"en\" };\n", + "var user1 = new Dictionary { [\"theme\"] = \"dark\", [\"fontSize\"] = \"14\" };\n", + "\n", + "base1.Merge(user1, overwrite: true);\n", + "display(base1[\"theme\"]); // \"dark\" (overwritten)\n", + "display(base1[\"fontSize\"]); // \"14\" (new key added)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Merge — null other is silently ignored; returns same dict instance for chaining\n", + "var dict1 = new Dictionary { [\"a\"] = 1 };\n", + "var result3 = dict1.Merge(null!);\n", + "\n", + "display(object.ReferenceEquals(dict1, result3)); // True (same instance)\n", + "display(dict1.Count); // 1 (unchanged)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 4. Bulk Add\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `AddRange` | `IDictionary, IEnumerable>?, bool overwrite=false → IDictionary` | original dict (mutated in-place) with all pairs added |\n", + "\n", + "Works with any `IEnumerable>` — including another dictionary, a list of tuples converted to KVPs, or LINQ projections. A null source is silently ignored." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AddRange — adds all pairs from a list; returns same dict for chaining\n", + "var inventory = new Dictionary { [\"apple\"] = 10 };\n", + "var incoming = new List>\n", + "{\n", + " new(\"banana\", 5),\n", + " new(\"cherry\", 20),\n", + "};\n", + "\n", + "inventory.AddRange(incoming);\n", + "display(inventory); // { apple: 10, banana: 5, cherry: 20 }" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AddRange — overwrite: false (default) keeps existing values for duplicate keys\n", + "var inv2 = new Dictionary { [\"apple\"] = 10, [\"banana\"] = 5 };\n", + "var restock = new List> { new(\"apple\", 99), new(\"mango\", 15) };\n", + "\n", + "inv2.AddRange(restock, overwrite: false);\n", + "display(inv2[\"apple\"]); // 10 (existing kept)\n", + "display(inv2[\"mango\"]); // 15 (new key added)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AddRange — overwrite: true replaces existing values\n", + "var inv3 = new Dictionary { [\"apple\"] = 10, [\"banana\"] = 5 };\n", + "var restock2 = new List> { new(\"apple\", 99), new(\"mango\", 15) };\n", + "\n", + "inv3.AddRange(restock2, overwrite: true);\n", + "display(inv3[\"apple\"]); // 99 (overwritten)\n", + "display(inv3[\"mango\"]); // 15" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AddRange — works with any IEnumerable; here from a LINQ projection\n", + "var codes = new[] { \"USD\", \"GBP\", \"EUR\" };\n", + "var rates = new Dictionary();\n", + "\n", + "rates.AddRange(codes.Select((c, i) => new KeyValuePair(c, 1.0 + i * 0.1)));\n", + "display(rates); // { USD: 1.0, GBP: 1.1, EUR: 1.2 }" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AddRange — null pairs are silently ignored\n", + "var inv4 = new Dictionary { [\"a\"] = 1 };\n", + "inv4.AddRange(null!);\n", + "display(inv4.Count); // 1 (unchanged, no exception)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 5. Filtering In-Place\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `RemoveWhere` | `IDictionary, Func → IDictionary` | original dict (mutated in-place) with matching keys removed |\n", + "\n", + "Collects matching keys first, then removes them — safe against the \"collection modified during enumeration\" error for the keys being removed. The predicate receives each key." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// RemoveWhere — removes all keys matching the predicate\n", + "var settings = new Dictionary\n", + "{\n", + " [\"db.host\"] = \"localhost\",\n", + " [\"db.port\"] = \"5432\",\n", + " [\"cache.host\"] = \"redis\",\n", + " [\"cache.ttl\"] = \"300\",\n", + " [\"app.name\"] = \"MyApp\",\n", + "};\n", + "\n", + "settings.RemoveWhere(k => k.StartsWith(\"cache.\"));\n", + "display(settings); // { db.host: localhost, db.port: 5432, app.name: MyApp }" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// RemoveWhere — no matching keys: dict is unchanged\n", + "var nums = new Dictionary { [\"a\"] = 1, [\"b\"] = 2 };\n", + "nums.RemoveWhere(k => k == \"z\");\n", + "display(nums.Count); // 2 (unchanged)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// RemoveWhere — returns same dict instance for chaining\n", + "var env = new Dictionary\n", + "{\n", + " [\"DEBUG\"] = \"true\",\n", + " [\"SECRET\"] = \"s3cr3t\",\n", + " [\"HOST\"] = \"prod.example.com\",\n", + " [\"API_KEY\"] = \"abc123\",\n", + "};\n", + "\n", + "// Remove sensitive keys, then check what's left\n", + "var safe = env.RemoveWhere(k => k.Contains(\"KEY\") || k.Contains(\"SECRET\"));\n", + "display(object.ReferenceEquals(env, safe)); // True (same instance)\n", + "display(safe); // { DEBUG: true, HOST: prod.example.com }" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// RemoveWhere — throws ArgumentNullException for null predicate\n", + "var d2 = new Dictionary { [\"a\"] = 1 };\n", + "\n", + "try { d2.RemoveWhere(null!); }\n", + "catch (ArgumentNullException e) { display($\"null predicate: {e.ParamName}\"); } // predicate" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 6. Read-Only View\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `AsReadOnly` | `IDictionary where TKey:notnull → IReadOnlyDictionary` | a live read-only view over the original dictionary |\n", + "\n", + "> **Live view, not a copy.** Mutations to the underlying dictionary are visible through the returned `IReadOnlyDictionary`. Pass it to subsystems that should read but not write the data.\n", + ">\n", + "> **Note:** Called using static syntax (`DictionaryExtensions.AsReadOnly(dict)`) to disambiguate from `System.Collections.Generic.CollectionExtensions.AsReadOnly`.\n", + ">\n", + "> **Constraint:** `TKey` must be non-nullable (`where TKey : notnull`) — a requirement of the underlying `ReadOnlyDictionary`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AsReadOnly — wraps the dict as IReadOnlyDictionary; reads work normally\n", + "var prices = new Dictionary\n", + "{\n", + " [\"apple\"] = 1.20m,\n", + " [\"banana\"] = 0.50m,\n", + "};\n", + "\n", + "IReadOnlyDictionary readOnly = DictionaryExtensions.AsReadOnly(prices);\n", + "display(readOnly.Count); // 2\n", + "display(readOnly[\"apple\"]); // 1.20\n", + "display(readOnly.ContainsKey(\"banana\")); // True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AsReadOnly — live view: mutations to the underlying dict are visible through the wrapper\n", + "var source = new Dictionary { [\"a\"] = 1 };\n", + "var view = DictionaryExtensions.AsReadOnly(source);\n", + "\n", + "display(view.Count); // 1\n", + "\n", + "source[\"b\"] = 99; // mutate the underlying dict\n", + "display(view.Count); // 2 (change is visible through the view)\n", + "display(view[\"b\"]); // 99" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// AsReadOnly — throws ArgumentNullException for null dict\n", + "try { DictionaryExtensions.AsReadOnly(null!); }\n", + "catch (ArgumentNullException e) { display($\"null dict: {e.ParamName}\"); } // dict" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 7. Chaining Examples\n", + "\n", + "`Merge`, `AddRange`, and `RemoveWhere` all return the same dictionary instance, making them chainable. The examples below show realistic pipelines that compose several methods." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Build a config from defaults + environment overrides, then expose read-only\n", + "`Merge → RemoveWhere → AsReadOnly`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "var appDefaults = new Dictionary\n", + "{\n", + " [\"log.level\"] = \"info\",\n", + " [\"log.format\"] = \"json\",\n", + " [\"db.pool\"] = \"5\",\n", + " [\"debug\"] = \"false\",\n", + "};\n", + "\n", + "var envVars = new Dictionary\n", + "{\n", + " [\"log.level\"] = \"warn\", // override log level\n", + " [\"db.pool\"] = \"20\", // override pool size\n", + " [\"SECRET_KEY\"] = \"abc\", // sensitive — should be stripped\n", + "};\n", + "\n", + "// Merge env overrides, strip sensitive keys, expose as read-only\n", + "var config = DictionaryExtensions.AsReadOnly(\n", + " appDefaults\n", + " .Merge(envVars, overwrite: true)\n", + " .RemoveWhere(k => k.Contains(\"SECRET\"))\n", + ");\n", + "\n", + "display(config[\"log.level\"]); // \"warn\" (overridden by env)\n", + "display(config[\"db.pool\"]); // \"20\" (overridden by env)\n", + "display(config[\"log.format\"]); // \"json\" (default kept)\n", + "display(config.ContainsKey(\"SECRET_KEY\")); // False (stripped)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Bulk-populate a registry, then filter obsolete entries\n", + "`AddRange → RemoveWhere`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Start with some registered handlers\n", + "var handlers = new Dictionary { [\"v1/orders\"] = \"OrderHandlerV1\" };\n", + "\n", + "// Add new routes in bulk\n", + "var newRoutes = new List>\n", + "{\n", + " new(\"v2/orders\", \"OrderHandlerV2\"),\n", + " new(\"v2/products\", \"ProductHandlerV2\"),\n", + " new(\"v1/products\", \"ProductHandlerV1\"), // legacy — will be pruned\n", + " new(\"health\", \"HealthHandler\"),\n", + "};\n", + "\n", + "// Add routes then immediately prune the old v1 ones\n", + "handlers\n", + " .AddRange(newRoutes)\n", + " .RemoveWhere(k => k.StartsWith(\"v1/\"));\n", + "\n", + "display(handlers);\n", + "// { v2/orders: OrderHandlerV2, v2/products: ProductHandlerV2, health: HealthHandler }" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Lazy-initialise grouped buckets with GetOrAdd, then read-only snapshot\n", + "`GetOrAdd → AddRange → AsReadOnly`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Accumulate events into per-type buckets\n", + "var buckets = new Dictionary>();\n", + "\n", + "var events = new[]\n", + "{\n", + " (\"order\", \"order-1001\"),\n", + " (\"payment\", \"pay-501\"),\n", + " (\"order\", \"order-1002\"),\n", + " (\"payment\", \"pay-502\"),\n", + " (\"shipment\",\"ship-201\"),\n", + "};\n", + "\n", + "foreach (var (type, id) in events)\n", + " buckets.GetOrAdd(type, _ => new List()).Add(id);\n", + "\n", + "// Expose as read-only before passing to another layer\n", + "var snapshot = DictionaryExtensions.AsReadOnly(buckets);\n", + "\n", + "display(snapshot[\"order\"]); // [\"order-1001\", \"order-1002\"]\n", + "display(snapshot[\"payment\"]); // [\"pay-501\", \"pay-502\"]\n", + "display(snapshot[\"shipment\"]); // [\"ship-201\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Merge multiple sources in priority order\n", + "`Merge (overwrite:false) × 3`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Highest-priority source first; each subsequent merge skips already-set keys\n", + "var commandLine = new Dictionary { [\"port\"] = \"9090\" };\n", + "var envVars2 = new Dictionary { [\"port\"] = \"8080\", [\"host\"] = \"prod.example.com\" };\n", + "var fileConfig = new Dictionary { [\"port\"] = \"3000\", [\"host\"] = \"localhost\", [\"debug\"] = \"false\" };\n", + "\n", + "// Start with CLI args (highest priority), layer env vars, then file config\n", + "var resolved = commandLine\n", + " .Merge(envVars2, overwrite: false) // env can't override CLI\n", + " .Merge(fileConfig, overwrite: false); // file can't override CLI or env\n", + "\n", + "display(resolved[\"port\"]); // \"9090\" (CLI wins)\n", + "display(resolved[\"host\"]); // \"prod.example.com\" (env wins over file)\n", + "display(resolved[\"debug\"]); // \"false\" (only in file)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Safe lookup with fallback using GetOrAdd as a memoisation cache\n", + "`GetValueOrDefault → GetOrAdd`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "dotnet_interactive": { + "language": "csharp" + } + }, + "outputs": [], + "source": [ + "// Cheap probe with GetValueOrDefault before the more expensive GetOrAdd\n", + "var memo = new Dictionary();\n", + "\n", + "long Fib(int n)\n", + "{\n", + " if (n <= 1) return n;\n", + " var cached = DictionaryExtensions.GetValueOrDefault(memo, n);\n", + " if (cached != 0) return cached; // already computed\n", + " return memo.GetOrAdd(n, k => Fib(k - 1) + Fib(k - 2));\n", + "}\n", + "\n", + "display(Fib(10)); // 55\n", + "display(Fib(20)); // 6765\n", + "display(memo.Count); // number of values cached" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "language_info": { + "name": "polyglot-notebook" + }, + "polyglot_notebook": { + "kernelInfo": { + "defaultKernelName": "csharp", + "items": [ + { + "aliases": [], + "name": "csharp" + } + ] + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/CSharpHelperExtensions.Test/CSharpHelperExtensions.Test.csproj b/src/CSharpHelperExtensions.Test/CSharpHelperExtensions.Test.csproj index 868f5a5..5b8d5cc 100644 --- a/src/CSharpHelperExtensions.Test/CSharpHelperExtensions.Test.csproj +++ b/src/CSharpHelperExtensions.Test/CSharpHelperExtensions.Test.csproj @@ -1,31 +1,29 @@ + + net10.0 + false + ReusableExtensions.Unittest + - - net10.0 - false - ReusableExtensions.Unittest - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - + + + + + + diff --git a/src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs b/src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs new file mode 100644 index 0000000..958797e --- /dev/null +++ b/src/CSharpHelperExtensions.Test/DictionaryExtensionTest.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using CSharpHelperExtensions.Dictionaries; +using Shouldly; +using Xunit; +using static global::CSharpHelperExtensions.Dictionaries.DictionaryExtensions; + +namespace CSharpHelperExtensions.Test; + +public class DictionaryExtensionTest +{ + [Fact] + public void GetOrAdd_KeyExists_ReturnsExistingValue() + { + var dict = new Dictionary { ["a"] = 1 }; + var factoryCalled = false; + var result = dict.GetOrAdd( + "a", + _ => + { + factoryCalled = true; + return 99; + } + ); + result.ShouldBe(1); + factoryCalled.ShouldBeFalse(); + } + + [Fact] + public void GetOrAdd_KeyMissing_InvokesFactoryAddsAndReturnsValue() + { + var dict = new Dictionary(); + var result = dict.GetOrAdd("b", key => 42); + result.ShouldBe(42); + dict["b"].ShouldBe(42); + } + + [Fact] + public void GetOrAdd_NullFactory_Throws() + { + var dict = new Dictionary(); + Should.Throw(() => dict.GetOrAdd("a", null!)); + } + + [Fact] + public void GetOrAdd_NullKey_Throws() + { + var dict = new Dictionary(); + Should.Throw(() => dict.GetOrAdd(null!, _ => 42)); + } + + [Fact] + public void Merge_NoOverlap_AddsAllEntries() + { + var dict = new Dictionary { ["a"] = 1 }; + var other = new Dictionary { ["b"] = 2 }; + var result = dict.Merge(other); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(2); + dict["b"].ShouldBe(2); + } + + [Fact] + public void Merge_DuplicateKey_OverwriteFalse_KeepsExisting() + { + var dict = new Dictionary { ["a"] = 1 }; + var other = new Dictionary { ["a"] = 99 }; + dict.Merge(other, overwrite: false); + dict["a"].ShouldBe(1); + } + + [Fact] + public void Merge_DuplicateKey_OverwriteTrue_ReplacesExisting() + { + var dict = new Dictionary { ["a"] = 1 }; + var other = new Dictionary { ["a"] = 99 }; + dict.Merge(other, overwrite: true); + dict["a"].ShouldBe(99); + } + + [Fact] + public void Merge_NullOther_NoOp() + { + var dict = new Dictionary { ["a"] = 1 }; + var result = dict.Merge(null!); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(1); + } + + [Fact] + public void AddRange_NoOverlap_AddsAllPairs() + { + var dict = new Dictionary { ["a"] = 1 }; + var pairs = new List> { new("b", 2), new("c", 3) }; + var result = dict.AddRange(pairs); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(3); + } + + [Fact] + public void AddRange_DuplicateKey_OverwriteFalse_KeepsExisting() + { + var dict = new Dictionary { ["a"] = 1 }; + var pairs = new List> { new("a", 99) }; + dict.AddRange(pairs, overwrite: false); + dict["a"].ShouldBe(1); + } + + [Fact] + public void AddRange_DuplicateKey_OverwriteTrue_ReplacesExisting() + { + var dict = new Dictionary { ["a"] = 1 }; + var pairs = new List> { new("a", 99) }; + dict.AddRange(pairs, overwrite: true); + dict["a"].ShouldBe(99); + } + + [Fact] + public void AddRange_NullPairs_NoOp() + { + var dict = new Dictionary { ["a"] = 1 }; + var result = dict.AddRange(null!); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(1); + } + + [Fact] + public void RemoveWhere_RemovesMatchingKeys() + { + var dict = new Dictionary + { + ["a"] = 1, + ["b"] = 2, + ["c"] = 3, + }; + var result = dict.RemoveWhere(k => k == "a" || k == "c"); + result.ShouldBeSameAs(dict); + dict.Count.ShouldBe(1); + dict.ContainsKey("b").ShouldBeTrue(); + } + + [Fact] + public void RemoveWhere_NoMatch_LeavesAllEntries() + { + var dict = new Dictionary { ["a"] = 1, ["b"] = 2 }; + dict.RemoveWhere(k => k == "z"); + dict.Count.ShouldBe(2); + } + + [Fact] + public void RemoveWhere_EmptyDict_NoOp() + { + var dict = new Dictionary(); + dict.RemoveWhere(k => true); + dict.Count.ShouldBe(0); + } + + [Fact] + public void RemoveWhere_NullPredicate_Throws() + { + var dict = new Dictionary { ["a"] = 1 }; + Should.Throw(() => dict.RemoveWhere(null!)); + } + + [Fact] + public void AsReadOnly_ReturnsIReadOnlyDictionary() + { + var dict = new Dictionary { ["a"] = 1, ["b"] = 2 }; + // Qualify to disambiguate from System.Collections.Generic.CollectionExtensions.AsReadOnly + IReadOnlyDictionary readOnly = DictionaryExtensions.AsReadOnly(dict); + readOnly.Count.ShouldBe(2); + readOnly["a"].ShouldBe(1); + } + + [Fact] + public void AsReadOnly_ReflectsMutationsToUnderlying() + { + var dict = new Dictionary { ["a"] = 1 }; + // Qualify to disambiguate from System.Collections.Generic.CollectionExtensions.AsReadOnly + var readOnly = DictionaryExtensions.AsReadOnly(dict); + dict["b"] = 2; + readOnly.Count.ShouldBe(2); + readOnly["b"].ShouldBe(2); + } + + [Fact] + public void GetValueOrDefault_KeyExists_ReturnsValue() + { + var dict = new Dictionary { ["a"] = 42 }; + DictionaryExtensions.GetValueOrDefault(dict, "a").ShouldBe(42); + } + + [Fact] + public void GetValueOrDefault_KeyMissing_ReturnsDefault() + { + var dict = new Dictionary { ["a"] = 1 }; + DictionaryExtensions.GetValueOrDefault(dict, "z").ShouldBe(0); + } + + [Fact] + public void GetValueOrDefault_NullDict_ReturnsDefault() + { + IDictionary? dict = null; + DictionaryExtensions.GetValueOrDefault(dict, "a").ShouldBe(0); + } + + [Fact] + public void GetValueOrDefault_NullKey_ReturnsDefault() + { + var dict = new Dictionary { ["a"] = "hello" }; + DictionaryExtensions.GetValueOrDefault(dict, null!).ShouldBeNull(); + } + + [Fact] + public void GetValueOrDefault_ReferenceType_KeyMissing_ReturnsNull() + { + var dict = new Dictionary { ["a"] = "hello" }; + DictionaryExtensions.GetValueOrDefault(dict, "z").ShouldBeNull(); + } +} diff --git a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs index 71fae9a..69d2f4c 100644 --- a/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/EnumerableExtensionTest.cs @@ -14,7 +14,7 @@ public class EnumerableExtensionTest [Fact] public void IsNullOrEmpty_Test() { - ((List) null).IsNullOrEmpty().ShouldBeTrue(); + ((List)null).IsNullOrEmpty().ShouldBeTrue(); var emptyList = new List(); emptyList.IsNullOrEmpty().ShouldBeTrue(); @@ -25,40 +25,36 @@ public void IsNullOrEmpty_Test() [Fact] public void CleanNullOrEmpty_Test() { - var stringList = - new List() { "Magic", "Bean", "Stalk", "Giant" }; + var stringList = new List() { "Magic", "Bean", "Stalk", "Giant" }; IEnumerable numEnumerable = new List() { 1, null, 2 }; - var strListWithNullEmptyWs = - new List() - { "Magic", null, "Bean", "Stalk", "", "Giant", " " }; + var strListWithNullEmptyWs = new List() + { + "Magic", + null, + "Bean", + "Stalk", + "", + "Giant", + " ", + }; - var expectedStrList = - new List { "Magic", "Bean", "Stalk", "Giant" }; + var expectedStrList = new List { "Magic", "Bean", "Stalk", "Giant" }; var expectedIntList = new List { 1, 2 }; stringList.CleanNullOrEmptyItems().ShouldBe(expectedStrList); - numEnumerable - .CleanNullOrEmptyItems() - .ShouldBe(expectedIntList); - strListWithNullEmptyWs - .CleanNullOrEmptyItems() - .ShouldBe(expectedStrList); + numEnumerable.CleanNullOrEmptyItems().ShouldBe(expectedIntList); + strListWithNullEmptyWs.CleanNullOrEmptyItems().ShouldBe(expectedStrList); } [Fact] public void ContainsOnly_Test() { - var stringList = - new List() { "Magic", "Bean", "Stalk", "Giant" }; + var stringList = new List() { "Magic", "Bean", "Stalk", "Giant" }; stringList.ContainsOnly("Magic").ShouldBeFalse(); - stringList - .ContainsOnly("Magic", "Bean", "Stalk", "Giant") - .ShouldBeTrue(); - stringList - .ContainsOnly("Magic", "Bean", "Stalk", "Jack") - .ShouldBeFalse(); + stringList.ContainsOnly("Magic", "Bean", "Stalk", "Giant").ShouldBeTrue(); + stringList.ContainsOnly("Magic", "Bean", "Stalk", "Jack").ShouldBeFalse(); var integerList = new List() { 123 }; integerList.ContainsOnly(123).ShouldBeTrue(); @@ -68,31 +64,22 @@ public void ContainsOnly_Test() [Fact] public void Verify_Enumerable_AreEqual() { - var stringList = - new List() { "Magic", "Bean", "Stalk", "Giant" }; + var stringList = new List() { "Magic", "Bean", "Stalk", "Giant" }; - var stringList2 = - new List() { "Magic", "Bean", "Stalk", "Giant" }; + var stringList2 = new List() { "Magic", "Bean", "Stalk", "Giant" }; stringList.AreEqual(stringList).ShouldBeTrue(); stringList.AreEqual(stringList2).ShouldBeTrue(); - stringList2 - .AreEqual(stringList2, Compare.InOrder) - .ShouldBeTrue(); - stringList2 - .AreEqual(stringList2, Compare.NoOrder) - .ShouldBeTrue(); + stringList2.AreEqual(stringList2, Compare.InOrder).ShouldBeTrue(); + stringList2.AreEqual(stringList2, Compare.NoOrder).ShouldBeTrue(); stringList2 = new List() { "Magic", "Bean", "Stalk" }; stringList.AreEqual(stringList2).ShouldBeFalse(); - stringList2 = - new List() { "Giant", "Magic", "Bean", "Stalk" }; + stringList2 = new List() { "Giant", "Magic", "Bean", "Stalk" }; stringList.AreEqual(stringList2).ShouldBeTrue(); - stringList - .AreEqual(stringList2, Compare.InOrder) - .ShouldBeFalse(); + stringList.AreEqual(stringList2, Compare.InOrder).ShouldBeFalse(); } [Fact] @@ -105,12 +92,9 @@ public void AreEqual_True_When_Source_NullOrEmpty() stringList2 = new List(); stringList.AreEqual(stringList2).ShouldBeTrue(); - stringList2 = - new List() { "Giant", "Magic", "Bean", "Stalk" }; + stringList2 = new List() { "Giant", "Magic", "Bean", "Stalk" }; var result = stringList.AreEqual(stringList2); - stringList - .AreEqual(stringList2, Compare.InOrder) - .ShouldBeFalse(); + stringList.AreEqual(stringList2, Compare.InOrder).ShouldBeFalse(); } [Fact] @@ -134,11 +118,10 @@ public void ForEach_IterateListOfInteger_ReturnSum() IEnumerable source = new List() { 1, 2, 3, 4 }; int expected = 10; int actual = 0; - var returnValue =source - .ForEach(item => - { - actual += item; - }); + var returnValue = source.ForEach(item => + { + actual += item; + }); actual.ShouldBe(expected); } @@ -156,11 +139,10 @@ public void Reduce_Add4Numbers_WithInitialValue_ReturnExpected() { IEnumerable source = new List() { 1, 2, 3, 4 }; Decimal expected = 11; - var actual = - source - .Reduce((item, currentTotal) => - currentTotal + item, - 1); + var actual = source.Reduce( + (item, currentTotal) => currentTotal + item, + 1 + ); actual.ShouldBe(expected); actual.GetType().ShouldBe(expected.GetType()); } @@ -170,14 +152,13 @@ public void Reduce_Add4Numbers_WithInitialValue_Decimal_ReturnExpected() { IEnumerable source = new List() { 1, 2, 3, 4 }; Decimal expected = 11.5m; - var actual = - source - .Reduce((item, currentTotal) => - currentTotal + item, - 1.5m); + var actual = source.Reduce( + (item, currentTotal) => currentTotal + item, + 1.5m + ); actual.ShouldBe(expected); actual.GetType().ShouldBe(expected.GetType()); - } + } [Fact] public void HasAny_ReturnsTrue_WhenSequenceHasElements() @@ -306,8 +287,7 @@ public void WithIndex_OnNullSource_ReturnsEmpty() [Fact] public void ToDictionarySafe_CreatesDictionaryFromSequence() { - var result = new[] { ("a", 1), ("b", 2) } - .ToDictionarySafe(x => x.Item1, x => x.Item2); + var result = new[] { ("a", 1), ("b", 2) }.ToDictionarySafe(x => x.Item1, x => x.Item2); result["a"].ShouldBe(1); result["b"].ShouldBe(2); } @@ -315,16 +295,17 @@ public void ToDictionarySafe_CreatesDictionaryFromSequence() [Fact] public void ToDictionarySafe_KeepsLastValue_OnDuplicateKey() { - var result = new[] { ("a", 1), ("a", 99) } - .ToDictionarySafe(x => x.Item1, x => x.Item2); + var result = new[] { ("a", 1), ("a", 99) }.ToDictionarySafe(x => x.Item1, x => x.Item2); result["a"].ShouldBe(99); } [Fact] public void ToDictionarySafe_OnNullSource_ReturnsEmptyDictionary() { - var result = ((IEnumerable<(string, int)>)null) - .ToDictionarySafe(x => x.Item1, x => x.Item2); + var result = ((IEnumerable<(string, int)>)null).ToDictionarySafe( + x => x.Item1, + x => x.Item2 + ); result.ShouldBeEmpty(); } @@ -579,16 +560,18 @@ public void MaxByOrDefault_ReturnsNull_WhenSourceIsEmpty_ReferenceType() [Fact] public async Task SelectAsync_ProjectsEachElementConcurrently() { - var result = await new[] { 1, 2, 3 } - .SelectAsync(async x => { await Task.Yield(); return x * 2; }); + var result = await new[] { 1, 2, 3 }.SelectAsync(async x => + { + await Task.Yield(); + return x * 2; + }); result.ShouldBe(new[] { 2, 4, 6 }); } [Fact] public async Task SelectAsync_OnNullSource_ReturnsEmpty() { - var result = await ((IEnumerable)null) - .SelectAsync(async x => x * 2); + var result = await ((IEnumerable)null).SelectAsync(async x => x * 2); result.ShouldBeEmpty(); } @@ -598,14 +581,20 @@ public async Task SelectAsync_WithMaxParallel_CapsConcurrency() int concurrent = 0; int maxSeen = 0; - await System.Linq.Enumerable.Range(1, 10).ToList().SelectAsync(async x => - { - var c = Interlocked.Increment(ref concurrent); - Interlocked.Exchange(ref maxSeen, Math.Max(maxSeen, c)); - await Task.Delay(20); - Interlocked.Decrement(ref concurrent); - return x; - }, maxParallel: 3); + await System + .Linq.Enumerable.Range(1, 10) + .ToList() + .SelectAsync( + async x => + { + var c = Interlocked.Increment(ref concurrent); + Interlocked.Exchange(ref maxSeen, Math.Max(maxSeen, c)); + await Task.Delay(20); + Interlocked.Decrement(ref concurrent); + return x; + }, + maxParallel: 3 + ); maxSeen.ShouldBeLessThanOrEqualTo(3); } @@ -617,7 +606,7 @@ public async Task WhenAllList_ReturnsAllTaskResults_AsReadOnlyList() { Task.FromResult(1), Task.FromResult(2), - Task.FromResult(3) + Task.FromResult(3), }; IReadOnlyList result = await tasks.WhenAllList(); result.ShouldBe(new[] { 1, 2, 3 }); diff --git a/src/CSharpHelperExtensions.Test/StringExtensionTest.cs b/src/CSharpHelperExtensions.Test/StringExtensionTest.cs index 4952649..e8b76d0 100644 --- a/src/CSharpHelperExtensions.Test/StringExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/StringExtensionTest.cs @@ -5,6 +5,7 @@ using CSharpHelperExtensions.Strings; using Shouldly; using Xunit; + namespace CSharpHelperExtensions.Test { public class StringExtensionTest @@ -307,9 +308,7 @@ public void Verify_ReplaceMany_ReplacesMultiplePairs() [Fact] public void Verify_ReplaceMany_AppliesInOrder() { - "aaa" - .ReplaceMany(new[] { ("aaa", "bbb"), ("bbb", "ccc") }) - .ShouldBe("ccc"); + "aaa".ReplaceMany(new[] { ("aaa", "bbb"), ("bbb", "ccc") }).ShouldBe("ccc"); } [Fact] diff --git a/src/CSharpHelperExtensions/CSharpHelperExtensions.csproj b/src/CSharpHelperExtensions/CSharpHelperExtensions.csproj index 80faf33..05c94b5 100644 --- a/src/CSharpHelperExtensions/CSharpHelperExtensions.csproj +++ b/src/CSharpHelperExtensions/CSharpHelperExtensions.csproj @@ -1,14 +1,12 @@ + + net10.0 + CSharpHelperExtensions + CSharpHelperExtensions + latest + - - net10.0 - CSharpHelperExtensions - CSharpHelperExtensions - latest - - - - - - + + + diff --git a/src/CSharpHelperExtensions/DictionaryExtensions.cs b/src/CSharpHelperExtensions/DictionaryExtensions.cs new file mode 100644 index 0000000..a7a9ee0 --- /dev/null +++ b/src/CSharpHelperExtensions/DictionaryExtensions.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +#nullable enable +namespace CSharpHelperExtensions.Dictionaries; + +public static class DictionaryExtensions +{ + /// + /// Returns the value for if it exists; otherwise returns + /// . Returns when + /// or is . + /// + /// The key type. + /// The value type. + /// The dictionary to look up. A value returns . + /// The key to look up. A value returns . + /// The value for , or if not found. + public static TValue? GetValueOrDefault( + this IDictionary? dict, + TKey key + ) + { + if (dict is null || key is null) + { + return default; + } + + return dict.TryGetValue(key, out var value) ? value : default; + } + + /// + /// Returns the value for if it exists; otherwise invokes + /// , adds the result to the dictionary, and returns it. + /// + /// The key type. + /// The value type. + /// The dictionary to operate on. + /// The key to look up or add. + /// A function that produces the value when the key is missing. Receives the key as its argument. + /// The existing or newly added value. + /// Thrown when or is . + public static TValue GetOrAdd( + this IDictionary dict, + TKey key, + Func factory + ) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(factory); + + if (dict.TryGetValue(key, out var existing)) + { + return existing; + } + + var value = factory(key); + dict[key] = value; + return value; + } + + /// + /// Merges all entries from into this dictionary. + /// + /// The key type. + /// The value type. + /// The target dictionary. + /// The source dictionary to merge from. A value is silently ignored. + /// When , existing keys are overwritten. Defaults to (skip duplicates). + /// The original for fluent chaining. + public static IDictionary Merge( + this IDictionary dict, + IDictionary? other, + bool overwrite = false + ) + { + if (other is null) + { + return dict; + } + + foreach (var pair in other) + { + if (overwrite || !dict.ContainsKey(pair.Key)) + { + dict[pair.Key] = pair.Value; + } + } + + return dict; + } + + /// + /// Bulk-adds into this dictionary. + /// + /// The key type. + /// The value type. + /// The target dictionary. + /// The key-value pairs to add. A value is silently ignored. + /// When , existing keys are overwritten. Defaults to (skip duplicates). + /// The original for fluent chaining. + public static IDictionary AddRange( + this IDictionary dict, + IEnumerable>? pairs, + bool overwrite = false + ) + { + if (pairs is null) + { + return dict; + } + + foreach (var pair in pairs) + { + if (overwrite || !dict.ContainsKey(pair.Key)) + { + dict[pair.Key] = pair.Value; + } + } + + return dict; + } + + /// + /// Removes all entries whose key satisfies . + /// Mutates the dictionary in-place. + /// + /// The key type. + /// The value type. + /// The dictionary to filter. + /// A function that returns for keys to remove. + /// The original for fluent chaining. + /// Thrown when is . + public static IDictionary RemoveWhere( + this IDictionary dict, + Func predicate + ) + { + ArgumentNullException.ThrowIfNull(predicate); + + var keysToRemove = new List(); + foreach (var key in dict.Keys) + { + if (predicate(key)) + { + keysToRemove.Add(key); + } + } + + foreach (var key in keysToRemove) + { + dict.Remove(key); + } + + return dict; + } + + /// + /// Wraps this dictionary in a read-only view. + /// + /// The key type. + /// The value type. + /// The dictionary to wrap. + /// + /// A that reflects + /// subsequent mutations to the underlying dictionary (live view, not a copy). + /// + /// Thrown when is . + public static IReadOnlyDictionary AsReadOnly( + this IDictionary dict + ) + where TKey : notnull + { + ArgumentNullException.ThrowIfNull(dict); + return new ReadOnlyDictionary(dict); + } +} diff --git a/src/CSharpHelperExtensions/ValueExtensions.cs b/src/CSharpHelperExtensions/ValueExtensions.cs index 59987e3..399f8c9 100644 --- a/src/CSharpHelperExtensions/ValueExtensions.cs +++ b/src/CSharpHelperExtensions/ValueExtensions.cs @@ -11,16 +11,19 @@ public enum BetweenComparison { /// Inclusive on both ends: lower ≤ value ≤ upper. This is the default. None, + /// Exclusive on both ends: lower < value < upper. ExcludeBoth, + /// Exclusive lower bound, inclusive upper: lower < value ≤ upper. ExcludeLower, + /// Inclusive lower bound, exclusive upper: lower ≤ value < upper. - ExcludeUpper + ExcludeUpper, } + public static class ValueExtensions { - /// /// Determines whether a value falls within the range defined by and . /// @@ -45,15 +48,23 @@ public static class ValueExtensions /// 10.IsBetween(1, 10, BetweenComparison.ExcludeBoth) // false (both bounds excluded) /// /// - public static bool IsBetween(this T value, T lower, T upper, BetweenComparison comparison = BetweenComparison.None) + public static bool IsBetween( + this T value, + T lower, + T upper, + BetweenComparison comparison = BetweenComparison.None + ) where T : IComparable { return comparison switch { - BetweenComparison.ExcludeBoth => (value.CompareTo(lower) > 0) && (value.CompareTo(upper) < 0), - BetweenComparison.ExcludeLower => (value.CompareTo(lower) > 0) && (value.CompareTo(upper) <= 0), - BetweenComparison.ExcludeUpper => (value.CompareTo(lower) >= 0) && (value.CompareTo(upper) < 0), - _ => value.CompareTo(lower) >= 0 && value.CompareTo(upper) <= 0 + BetweenComparison.ExcludeBoth => (value.CompareTo(lower) > 0) + && (value.CompareTo(upper) < 0), + BetweenComparison.ExcludeLower => (value.CompareTo(lower) > 0) + && (value.CompareTo(upper) <= 0), + BetweenComparison.ExcludeUpper => (value.CompareTo(lower) >= 0) + && (value.CompareTo(upper) < 0), + _ => value.CompareTo(lower) >= 0 && value.CompareTo(upper) <= 0, }; } @@ -115,4 +126,3 @@ public static string ToJson(this T value, bool indentation = false) return value is null ? null : JsonConvert.SerializeObject(value, formatting); } } -