diff --git a/AGENTS.md b/AGENTS.md index 6ed3395..f27ba43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,16 @@ agent/sandbox shells, and SDK 10 additionally fails without scripts/dotnet.sh build db-subclass-to-dto.sln scripts/dotnet.sh test db-subclass-to-dto.sln scripts/run-tests.sh # full test suite, from repo root +scripts/run-console.sh # run the Before.Console demo (bash; delegates + # to dotnet.sh); app args go after `--` +``` + +Windows/PowerShell counterpart: + +```powershell +scripts\run-console.ps1 # run the Before.Console demo; app args go + # directly (the script inserts `--` for + # dotnet run); needs no DOTNET_CLI_HOME setup ``` The wrappers cd to the repo root (the test runner must run from the parent diff --git a/scripts/run-console.ps1 b/scripts/run-console.ps1 new file mode 100644 index 0000000..e895cf0 --- /dev/null +++ b/scripts/run-console.ps1 @@ -0,0 +1,39 @@ +# Run the Before.Console demo app (src/Before.Console/Program.cs) on Windows. +# +# Windows counterpart of scripts/run-console.sh: +# - runs from the repo root, like the other scripts +# - prefers mise (see mise.toml) and falls back to a dotnet on PATH +# +# Troubleshooting: if dotnet fails with "The user's home directory could not +# be determined" (some sandboxed shells), set DOTNET_CLI_HOME first: +# PS> $env:DOTNET_CLI_HOME = "$PWD\.dotnet-cli" +# +# Usage (from anywhere): +# PS> .\scripts\run-console.ps1 # no args +# PS> .\scripts\run-console.ps1 --verbose # args go to the program +# The script inserts the `--` separator for dotnet run itself. +# +# If script execution is blocked by policy: +# PS> powershell -ExecutionPolicy Bypass -File .\scripts\run-console.ps1 +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + $dotnetArgs = @('run', '--project', 'src/Before.Console') + if ($args.Count -gt 0) { + $dotnetArgs += '--' + $dotnetArgs += $args + } + + if (Get-Command mise -ErrorAction SilentlyContinue) { + mise exec -- dotnet @dotnetArgs + } + else { + dotnet @dotnetArgs + } + exit $LASTEXITCODE +} +finally { + Pop-Location +} diff --git a/scripts/run-console.sh b/scripts/run-console.sh new file mode 100755 index 0000000..4201cd6 --- /dev/null +++ b/scripts/run-console.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Run the Before.Console demo app (src/Before.Console/Program.cs). +# Delegates to scripts/dotnet.sh — see there for why the wrapper exists +# (mise-installed dotnet, DOTNET_CLI_HOME, run from repo root). +# +# Usage: scripts/run-console.sh [app args] +# scripts/run-console.sh # no args +# scripts/run-console.sh -- --verbose # args are passed to the program +set -euo pipefail +exec "$(cd "$(dirname "$0")" && pwd)/dotnet.sh" run --project src/Before.Console -- "$@" diff --git a/src/Before.Console/Program.cs b/src/Before.Console/Program.cs index 1b18d43..b828ed1 100644 --- a/src/Before.Console/Program.cs +++ b/src/Before.Console/Program.cs @@ -44,9 +44,6 @@ foreach (var client in DbBase.Context!.Tracked.OfType()) // ----- find by name ------------------------------------------------------- Console.WriteLine("\n=== Find by Name (null-returning) ==="); // No context argument: FindByName falls back to the DbBase.Context singleton. -// This is the ActiveRecord ergonomic — call sites don't carry the context, -// but the null that comes back on a miss can't tell "no such client" from -// "nobody configured the context". var found = Client.FindByName("Jane Doe"); if (found is not null) { @@ -66,7 +63,7 @@ try Console.WriteLine($"Found: {required.Name} [{required.Id}]"); Console.WriteLine($" Orders: {required.Orders.Count}"); } -catch (InvalidOperationException ex) +catch (ObjectNotFoundException ex) { Console.WriteLine($"Not found: {ex.Message}"); } @@ -79,7 +76,7 @@ try { Client.FindByNameRequired("Nobody Here"); } -catch (InvalidOperationException ex) +catch (ObjectNotFoundException ex) { var msg = ex.Message.ReplaceLineEndings(" ").Trim(); Console.WriteLine($"FindByNameRequired(\"Nobody Here\"): throws - {msg}"); diff --git a/src/Before/Client.cs b/src/Before/Client.cs index 2b3da1b..37a4ef0 100644 --- a/src/Before/Client.cs +++ b/src/Before/Client.cs @@ -43,9 +43,10 @@ public class Client : DbBase /// /// Throwing variant of . Returns - /// the matching or throws - /// with a message that includes the searched and - /// whether a context was configured. + /// the matching or throws + /// with a message that includes the searched . + /// Throws instead when no context at all + /// is configured (a configuration error, not a lookup miss). /// /// Delegates to . /// @@ -53,8 +54,11 @@ public class Client : DbBase /// The context whose tracked entities to search. When /// null, falls back to the singleton. /// The first client whose equals . + /// + /// Thrown when no matching client is found. + /// /// - /// Thrown when no matching client is found or no context is configured. + /// Thrown when no context is configured (neither passed explicitly nor set as the singleton). /// public static Client FindByNameRequired(string name, DbContext? db = null) => DbBase.FindRequired(c => c.Name == name, db, $"name == \"{name}\""); diff --git a/src/Before/DbBase.cs b/src/Before/DbBase.cs index 26085d9..4466f39 100644 --- a/src/Before/DbBase.cs +++ b/src/Before/DbBase.cs @@ -86,8 +86,9 @@ public class DbBase /// /// Throwing variant of . Finds the first entity /// whose runtime type matches and satisfies - /// . Throws - /// when no match is found or when no singleton is configured. + /// . Throws + /// when no match is found, and when no + /// singleton is configured (a configuration error, not a lookup miss). /// /// The exception message includes so /// callers can debug which lookup failed. @@ -98,8 +99,11 @@ public class DbBase /// A human-readable description of the predicate, used in the /// exception message when the search fails. /// The first matching entity. + /// + /// Thrown when no entity matches . + /// /// - /// Thrown when no entity matches or when no context is available. + /// Thrown when no context is available (neither passed explicitly nor set as the singleton). /// public static T FindRequired(Predicate predicate, DbContext? db, string predicateToString) where T : DbBase @@ -117,7 +121,7 @@ public class DbBase if (e is T candidate && predicate(candidate)) return candidate; - throw new InvalidOperationException( + throw new ObjectNotFoundException( $"FindRequired<{typeof(T).Name}>({predicateToString}) — " + $"no matching {typeof(T).Name} found in context."); } diff --git a/src/Before/ObjectNotFoundException.cs b/src/Before/ObjectNotFoundException.cs new file mode 100644 index 0000000..b4bb35e --- /dev/null +++ b/src/Before/ObjectNotFoundException.cs @@ -0,0 +1,17 @@ +namespace Before; + +/// +/// Thrown when a "required" ActiveRecord-style lookup — +/// / — +/// finds no matching entity in the context. +/// +/// Named after NHibernate's ObjectNotFoundException (Rails' ActiveRecord +/// raises ActiveRecord::RecordNotFound for the same situation), so a +/// lookup miss is distinguishable from unrelated +/// s such as EF's Single() +/// "Sequence contains no elements". +/// +public class ObjectNotFoundException : Exception +{ + public ObjectNotFoundException(string message) : base(message) { } +} diff --git a/tests/BeforeAfter.Tests/BeforeTests.cs b/tests/BeforeAfter.Tests/BeforeTests.cs index 763200e..c41f2c5 100644 --- a/tests/BeforeAfter.Tests/BeforeTests.cs +++ b/tests/BeforeAfter.Tests/BeforeTests.cs @@ -358,7 +358,8 @@ public class BeforeTests } // --------------------------------------------------------------------- - // (18) DbBase.FindRequired throws when no match is found. + // (18) DbBase.FindRequired throws ObjectNotFoundException when no match + // is found (the domain-specific lookup miss, not a BCL exception). // --------------------------------------------------------------------- [Fact] public void DbBase_FindRequired_T_throws_when_no_match() @@ -366,7 +367,7 @@ public class BeforeTests var db = new DbContext(); db.Attach(new Client { Name = "Acme" }); - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => DbBase.FindRequired(c => c.Name == "Nobody", db, "name == Nobody")); Assert.Contains("Nobody", ex.Message); @@ -376,6 +377,9 @@ public class BeforeTests // --------------------------------------------------------------------- // (19) DbBase.FindRequired throws with a useful message when no context is // configured (both explicit null and singleton null). + // + // Deliberately InvalidOperationException, not ObjectNotFoundException: + // a missing context is a configuration error, not a lookup miss. // --------------------------------------------------------------------- [Fact] public void DbBase_FindRequired_T_throws_when_no_context_configured() @@ -405,7 +409,8 @@ public class BeforeTests } // --------------------------------------------------------------------- - // (21) Client.FindByNameRequired throws when no match — message includes name. + // (21) Client.FindByNameRequired throws ObjectNotFoundException when no + // match — message includes name. // --------------------------------------------------------------------- [Fact] public void Client_FindByNameRequired_throws_with_name_when_not_found() @@ -413,7 +418,7 @@ public class BeforeTests var db = new DbContext(); db.Attach(new Client { Name = "Acme Corp" }); - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => Client.FindByNameRequired("Nobody", db)); Assert.Contains("Nobody", ex.Message); @@ -445,6 +450,7 @@ public class BeforeTests // --------------------------------------------------------------------- // (23) Client.FindByNameRequired throws with a useful message when the // singleton is not configured (no arg, no singleton). + // Stays InvalidOperationException: configuration error, not a miss. // --------------------------------------------------------------------- [Fact] public void Client_FindByNameRequired_throws_with_context_hint_when_singleton_null()