diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 84dc51c..baea726 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -18,8 +18,8 @@ jobs: with: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore src/CSharpHelperExtensions.slnx - name: Build - run: dotnet build --no-restore + run: dotnet build src/CSharpHelperExtensions.slnx --no-restore - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test src/CSharpHelperExtensions.slnx --no-build --verbosity normal diff --git a/CLAUDE.md b/CLAUDE.md index 307ee74..e3a6282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,15 +53,15 @@ The library splits extensions across three namespaces — callers must import th | File | Namespace | Key types | |------|-----------|-----------| -| `GenericExtensions.cs` | `CSharpHelperExtensions` | `In`, `IsNullOrEmpty` (string), `IsBetween`, `ToJson` | +| `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` | -`IsNullOrEmpty` exists in **both** `GenericExtensions` (for `string`) and `EnumerableExtensions` (for `IEnumerable`). Be careful about which namespace is imported. +`IsNullOrEmpty` exists in **both** `StringExtensions` (for `string`, namespace `CSharpHelperExtensions.Strings`) and `EnumerableExtensions` (for `IEnumerable`, namespace `CSharpHelperExtensions.Enumerable`). Be careful about which namespace is imported. ### `BetweenComparison` enum -Defined in `GenericExtensions.cs`, controls how `IsBetween` handles bounds: +Defined in `ValueExtensions.cs` (namespace `CSharpHelperExtensions.Values`), controls how `IsBetween` handles bounds: - `None` (default) — inclusive on both ends - `ExcludeBoth` — exclusive on both ends - `ExcludeLower` — excludes lower bound, includes upper diff --git a/CSharpHelperExtensions.slnx b/CSharpHelperExtensions.slnx deleted file mode 100644 index 8e42b2b..0000000 --- a/CSharpHelperExtensions.slnx +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/README.md b/README.md index ccffed1..73e8694 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,14 @@ Don't Repeat Yourself Extensions A set of helper extension methods that are used very often when coding +## Namespaces + +| Namespace | Methods | +|---|---| +| `CSharpHelperExtensions.Values` | `In`, `IsBetween`, `ToJson` | +| `CSharpHelperExtensions.Enumerable` | All `IEnumerable` extensions | +| `CSharpHelperExtensions.Strings` | All `string` extensions | + 1. _**In() Method**_ Checks to see if a item is part of the quick list @@ -67,6 +75,7 @@ The item being compared needs to be comparable (IComparable) Usage ```c# +// using CSharpHelperExtensions.Values; decimal value = 3; decimal lower = 1; decimal upper = 3; diff --git a/sample/value-extensions.ipynb b/sample/value-extensions.ipynb new file mode 100644 index 0000000..6bb3f25 --- /dev/null +++ b/sample/value-extensions.ipynb @@ -0,0 +1,486 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": [ + "# Value Extensions\n", + "\n", + "Runnable samples for every method in `CSharpHelperExtensions.Values`. \n", + "Run the **Setup** cell first, then any section independently.\n", + "\n", + "| Section | Methods |\n", + "|---|---|\n", + "| [1. In](#1-in) | `In` |\n", + "| [2. IsBetween](#2-isbetween) | `IsBetween` · `BetweenComparison` |\n", + "| [3. ToJson](#3-tojson) | `ToJson` |\n", + "| [4. Chaining Examples](#4-chaining-examples) | Composing value extensions |" + ] + }, + { + "cell_type": "markdown", + "id": "e5f6a7b8", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "> **Run this cell first.** It loads the compiled library and imports the required namespace.\n", + ">\n", + "> Build first if the DLL is missing: `dotnet build` from the repo root." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9d0e1f2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "#r \"../src/CSharpHelperExtensions/bin/Debug/net10.0/CSharpHelperExtensions.dll\"\n", + "using CSharpHelperExtensions.Values; // In, IsBetween, BetweenComparison, ToJson" + ] + }, + { + "cell_type": "markdown", + "id": "a3b4c5d6", + "metadata": {}, + "source": [ + "---\n", + "## 1. In\n", + "\n", + "| Method | Signature | Returns |\n", + "|---|---|---|\n", + "| `In` | `T, params T[] → bool` | `true` when `value` equals any item in the set (like SQL `IN`) |\n", + "\n", + "Returns `false` when no candidates are supplied. Equality uses the default comparer for `T`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7f8a9b0", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// String membership — role check\n", + "display(\"admin\".In(\"admin\", \"superadmin\")); // True\n", + "display(\"guest\".In(\"admin\", \"superadmin\")); // False\n", + "display(\"superadmin\".In(\"admin\", \"superadmin\")); // True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1d2e3f4", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Integer membership\n", + "display(3.In(1, 2, 3, 4)); // True\n", + "display(5.In(1, 2, 3, 4)); // False\n", + "display(1.In(1, 2, 3, 4)); // True (first element)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5b6c7d8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Empty set — always false\n", + "display(\"x\".In()); // False (no candidates supplied)\n", + "display(42.In()); // False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9f0a1b2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Enum membership\n", + "enum HttpMethod { Get, Post, Put, Delete, Patch }\n", + "\n", + "var method = HttpMethod.Post;\n", + "\n", + "display(method.In(HttpMethod.Post, HttpMethod.Put, HttpMethod.Patch)); // True (mutating methods)\n", + "display(method.In(HttpMethod.Get, HttpMethod.Delete)); // False\n", + "display(HttpMethod.Get.In(HttpMethod.Get, HttpMethod.Post)); // True" + ] + }, + { + "cell_type": "markdown", + "id": "c3d4e5f6", + "metadata": {}, + "source": [ + "---\n", + "## 2. IsBetween\n", + "\n", + "| `BetweenComparison` mode | Behaviour |\n", + "|---|---|\n", + "| `None` (default) | Inclusive on both ends: `lower ≤ value ≤ upper` |\n", + "| `ExcludeBoth` | Exclusive on both ends: `lower < value < upper` |\n", + "| `ExcludeLower` | Exclusive lower, inclusive upper: `lower < value ≤ upper` |\n", + "| `ExcludeUpper` | Inclusive lower, exclusive upper: `lower ≤ value < upper` |\n", + "\n", + "Works on any type that implements `IComparable` — integers, `DateTime`, `string`, etc." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7b8c9d0", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.None (default) — inclusive on both ends\n", + "display(5.IsBetween(1, 10)); // True (interior)\n", + "display(1.IsBetween(1, 10)); // True (lower bound included)\n", + "display(10.IsBetween(1, 10)); // True (upper bound included)\n", + "display(0.IsBetween(1, 10)); // False (below lower)\n", + "display(11.IsBetween(1, 10)); // False (above upper)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1f2a3b4", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.ExcludeBoth — exclusive on both ends\n", + "display(5.IsBetween(1, 10, BetweenComparison.ExcludeBoth)); // True (interior)\n", + "display(1.IsBetween(1, 10, BetweenComparison.ExcludeBoth)); // False (lower bound excluded)\n", + "display(10.IsBetween(1, 10, BetweenComparison.ExcludeBoth)); // False (upper bound excluded)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5d6e7f8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.ExcludeLower — exclusive lower, inclusive upper\n", + "display(5.IsBetween(1, 10, BetweenComparison.ExcludeLower)); // True\n", + "display(1.IsBetween(1, 10, BetweenComparison.ExcludeLower)); // False (lower excluded)\n", + "display(10.IsBetween(1, 10, BetweenComparison.ExcludeLower)); // True (upper included)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9b0c1d2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// BetweenComparison.ExcludeUpper — inclusive lower, exclusive upper\n", + "display(5.IsBetween(1, 10, BetweenComparison.ExcludeUpper)); // True\n", + "display(1.IsBetween(1, 10, BetweenComparison.ExcludeUpper)); // True (lower included)\n", + "display(10.IsBetween(1, 10, BetweenComparison.ExcludeUpper)); // False (upper excluded)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e3f4a5b6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Works on any IComparable — DateTime and string\n", + "\n", + "// DateTime range check\n", + "var start = new DateTime(2024, 1, 1);\n", + "var end = new DateTime(2024, 12, 31);\n", + "var mid = new DateTime(2024, 6, 15);\n", + "\n", + "display(mid.IsBetween(start, end)); // True\n", + "display(start.IsBetween(start, end)); // True (inclusive)\n", + "display(start.IsBetween(start, end, BetweenComparison.ExcludeBoth)); // False (excluded)\n", + "\n", + "// String lexicographic range\n", + "display(\"mango\".IsBetween(\"apple\", \"orange\")); // True\n", + "display(\"zebra\".IsBetween(\"apple\", \"orange\")); // False (lexically after \"orange\")" + ] + }, + { + "cell_type": "markdown", + "id": "c7d8e9f0", + "metadata": {}, + "source": [ + "---\n", + "## 3. ToJson\n", + "\n", + "| Parameter | Type | Default | Notes |\n", + "|---|---|---|---|\n", + "| `value` | `T` | — | Object to serialize; returns `null` when this is `null` |\n", + "| `indentation` | `bool` | `false` | `true` → pretty-printed; `false` → compact single-line |\n", + "\n", + "Serializes via **Newtonsoft.Json**. Works on both reference types and value types." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2e3f4", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Compact output (default)\n", + "display(new { Name = \"Alice\", Age = 30 }.ToJson()); // {\"Name\":\"Alice\",\"Age\":30}\n", + "\n", + "// With a list\n", + "display(new { Tags = new[] { \"a\", \"b\", \"c\" }, Active = true }.ToJson());\n", + "// {\"Tags\":[\"a\",\"b\",\"c\"],\"Active\":true}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5d6f7a8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Indented (pretty-printed) output\n", + "display(new { Name = \"Alice\", Age = 30 }.ToJson(indentation: true));\n", + "// {\n", + "// \"Name\": \"Alice\",\n", + "// \"Age\": 30\n", + "// }" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9c0d1e2", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Value types — works directly on int, bool, DateTime, etc.\n", + "display(42.ToJson()); // 42\n", + "display(true.ToJson()); // true\n", + "display(3.14.ToJson()); // 3.14\n", + "display(DateTime.Parse(\"2024-06-15\").ToJson()); // \"2024-06-15T00:00:00\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3a4b5c6", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Null-safe — returns null for null input (no exception)\n", + "display(((object)null).ToJson()); // null\n", + "display(((string)null).ToJson()); // null" + ] + }, + { + "cell_type": "markdown", + "id": "d7e8f9a0", + "metadata": {}, + "source": [ + "---\n", + "## 4. Chaining Examples\n", + "\n", + "Value extensions compose naturally. These pipelines show realistic scenarios that combine\n", + "multiple `ValueExtensions` methods together." + ] + }, + { + "cell_type": "markdown", + "id": "b1c2d3e4", + "metadata": {}, + "source": [ + "### Range guard before categorisation\n", + "`IsBetween` to validate a score is in a legal range, then categorise it" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f5a6b7c8", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Categorise a score only when it falls in the valid range [0, 100]\n", + "int[] scores = { -5, 0, 45, 75, 90, 101 };\n", + "\n", + "foreach (var score in scores)\n", + "{\n", + " if (!score.IsBetween(0, 100))\n", + " {\n", + " display($\"{score,4}: invalid score\");\n", + " continue;\n", + " }\n", + "\n", + " var grade = score.IsBetween(90, 100) ? \"A\" :\n", + " score.IsBetween(75, 89) ? \"B\" :\n", + " score.IsBetween(60, 74) ? \"C\" : \"F\";\n", + "\n", + " display($\"{score,4}: {grade}\");\n", + "}\n", + "// -5: invalid score\n", + "// 0: F\n", + "// 45: F\n", + "// 75: B\n", + "// 90: A\n", + "// 101: invalid score" + ] + }, + { + "cell_type": "markdown", + "id": "a9b0c1d3", + "metadata": {}, + "source": [ + "### Role check with conditional JSON serialisation\n", + "`In` to check access rights, `ToJson` to emit the audit record only when allowed" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5f6a7b9", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "var user = new { Name = \"Carol\", Role = \"editor\", LastLogin = \"2024-06-15\" };\n", + "\n", + "bool canWrite = user.Role.In(\"admin\", \"editor\"); // True\n", + "bool canDelete = user.Role.In(\"admin\", \"superadmin\"); // False\n", + "\n", + "display($\"Can write: {canWrite}\"); // True\n", + "display($\"Can delete: {canDelete}\"); // False\n", + "\n", + "// Only serialise an audit record for privileged roles\n", + "var auditJson = user.Role.In(\"admin\", \"editor\", \"superadmin\")\n", + " ? new { user.Name, user.Role, Action = \"login\" }.ToJson(indentation: true)\n", + " : null;\n", + "\n", + "display(auditJson);\n", + "// {\n", + "// \"Name\": \"Carol\",\n", + "// \"Role\": \"editor\",\n", + "// \"Action\": \"login\"\n", + "// }" + ] + }, + { + "cell_type": "markdown", + "id": "c3d4e5f7", + "metadata": {}, + "source": [ + "### Serialise only values within an allowed set\n", + "`In` as a filter guard, `ToJson` for the output payload" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a8b9c0d1", + "metadata": { + "vscode": { + "languageId": "csharp" + } + }, + "outputs": [], + "source": [ + "// Emit a JSON status event only for terminal states; ignore transient ones\n", + "string[] statuses = { \"pending\", \"running\", \"succeeded\", \"failed\", \"cancelled\" };\n", + "string[] terminalStates = { \"succeeded\", \"failed\", \"cancelled\" };\n", + "\n", + "foreach (var status in statuses)\n", + "{\n", + " var payload = status.In(terminalStates)\n", + " ? new { Status = status, Timestamp = \"2024-06-15T12:00:00Z\" }.ToJson()\n", + " : null;\n", + "\n", + " if (payload is not null)\n", + " display(payload);\n", + "}\n", + "// {\"Status\":\"succeeded\",\"Timestamp\":\"2024-06-15T12:00:00Z\"}\n", + "// {\"Status\":\"failed\",\"Timestamp\":\"2024-06-15T12:00:00Z\"}\n", + "// {\"Status\":\"cancelled\",\"Timestamp\":\"2024-06-15T12:00:00Z\"}" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".NET (C#)", + "language": "C#", + "name": ".net-csharp" + }, + "language_info": { + "name": "C#" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/CSharpHelperExtensions.Test/GenericExtensionTest.cs b/src/CSharpHelperExtensions.Test/ValueExtensionTest.cs similarity index 98% rename from src/CSharpHelperExtensions.Test/GenericExtensionTest.cs rename to src/CSharpHelperExtensions.Test/ValueExtensionTest.cs index 7d4c498..38ae3a8 100644 --- a/src/CSharpHelperExtensions.Test/GenericExtensionTest.cs +++ b/src/CSharpHelperExtensions.Test/ValueExtensionTest.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CSharpHelperExtensions.Strings; +using CSharpHelperExtensions.Values; using Shouldly; using Xunit; @@ -11,7 +12,7 @@ public class Person public string LastName { get; set; } } - public class GenericExtensionsTest + public class ValueExtensionsTest { [Fact] public void Verify_In_Exists() diff --git a/src/CSharpHelperExtensions.slnx b/src/CSharpHelperExtensions.slnx new file mode 100644 index 0000000..854d15f --- /dev/null +++ b/src/CSharpHelperExtensions.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/CSharpHelperExtensions/GenericExtensions.cs b/src/CSharpHelperExtensions/ValueExtensions.cs similarity index 85% rename from src/CSharpHelperExtensions/GenericExtensions.cs rename to src/CSharpHelperExtensions/ValueExtensions.cs index b9e3f2f..59987e3 100644 --- a/src/CSharpHelperExtensions/GenericExtensions.cs +++ b/src/CSharpHelperExtensions/ValueExtensions.cs @@ -1,13 +1,11 @@ using System; -using System.Collections.Generic; -using System.ComponentModel; using System.Linq; using Newtonsoft.Json; -namespace CSharpHelperExtensions; +namespace CSharpHelperExtensions.Values; /// -/// Controls which bounds are included when using . +/// Controls which bounds are included when using . /// public enum BetweenComparison { @@ -20,7 +18,7 @@ public enum BetweenComparison /// Inclusive lower bound, exclusive upper: lower ≤ value < upper. ExcludeUpper } -public static class GenericExtensions +public static class ValueExtensions { /// @@ -55,7 +53,7 @@ public static bool IsBetween(this T value, T lower, T upper, BetweenCompariso 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), - _ => (lower.CompareTo(value) <= 0) && (value.CompareTo(upper) <= 0) + _ => value.CompareTo(lower) >= 0 && value.CompareTo(upper) <= 0 }; } @@ -65,7 +63,6 @@ public static bool IsBetween(this T value, T lower, T upper, BetweenCompariso /// /// The value to look for. /// One or more candidate values to match against. - /// The type of the value and candidates. /// /// Returns when no candidates are supplied. /// Equality is determined by the default equality comparer for . @@ -83,7 +80,7 @@ public static bool IsBetween(this T value, T lower, T upper, BetweenCompariso /// public static bool In(this T value, params T[] input) { - return input is { } && input.Contains(value); + return input.Contains(value); } /// @@ -96,10 +93,6 @@ public static bool In(this T value, params T[] input) /// When , the JSON output is pretty-printed with indentation. /// Defaults to (compact, single-line output). /// - /// - /// The type of the object to serialize. Must be a reference type (class). - /// Value types (structs) must be boxed to before calling this method. - /// /// /// A JSON string representing , /// or if is . @@ -113,12 +106,13 @@ public static bool In(this T value, params T[] input) /// // "Name": "Alice", /// // "Age": 30 /// // } - /// ((object)null).ToJson() // null + /// 42.ToJson() // 42 /// /// - public static string ToJson(this T value, bool indentation = false) where T : class + public static string ToJson(this T value, bool indentation = false) { var formatting = indentation ? Formatting.Indented : Formatting.None; - return value == null ? null : JsonConvert.SerializeObject(value, formatting); + return value is null ? null : JsonConvert.SerializeObject(value, formatting); } } +