diff --git a/src/Before.Console/Program.cs b/src/Before.Console/Program.cs index 58b55d9..1b18d43 100644 --- a/src/Before.Console/Program.cs +++ b/src/Before.Console/Program.cs @@ -42,7 +42,7 @@ foreach (var client in DbBase.Context!.Tracked.OfType()) } // ----- find by name ------------------------------------------------------- -Console.WriteLine("\n=== 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 @@ -57,3 +57,30 @@ else { Console.WriteLine("Not found."); } + +// ----- find by name (throwing variant) ------------------------------------ +Console.WriteLine("\n=== Find by Name Required (throws instead of returning null) ==="); +try +{ + var required = Client.FindByNameRequired("Jane Doe"); + Console.WriteLine($"Found: {required.Name} [{required.Id}]"); + Console.WriteLine($" Orders: {required.Orders.Count}"); +} +catch (InvalidOperationException ex) +{ + Console.WriteLine($"Not found: {ex.Message}"); +} + +// Show the contrast with a non-existent name: +Console.WriteLine("\n=== Miss: null-returning vs throwing ==="); +var missNull = Client.FindByName("Nobody Here"); +Console.WriteLine($"FindByName(\"Nobody Here\"): {(missNull == null ? "null" : missNull.Name)}"); +try +{ + Client.FindByNameRequired("Nobody Here"); +} +catch (InvalidOperationException 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 11341b3..2b3da1b 100644 --- a/src/Before/Client.cs +++ b/src/Before/Client.cs @@ -40,6 +40,24 @@ public class Client : DbBase /// public static Client? FindByName(string name, DbContext? db = null) => DbBase.Find(c => c.Name == name, db); + + /// + /// Throwing variant of . Returns + /// the matching or throws + /// with a message that includes the searched and + /// whether a context was configured. + /// + /// Delegates to . + /// + /// The client name to search for. + /// 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 or no context is configured. + /// + 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 6c80e16..26085d9 100644 --- a/src/Before/DbBase.cs +++ b/src/Before/DbBase.cs @@ -82,4 +82,43 @@ public class DbBase return default; } + + /// + /// 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. + /// + /// The exception message includes so + /// callers can debug which lookup failed. + /// + /// Filter applied to candidates of type . + /// The context whose tracked entities to search. When null, falls back + /// to the singleton. + /// 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 or when no context is available. + /// + public static T FindRequired(Predicate predicate, DbContext? db, string predicateToString) + where T : DbBase + { + db ??= Context; + + if (db is null) + throw new InvalidOperationException( + $"Cannot perform FindRequired<{typeof(T).Name}>: " + + $"no DbContext configured (neither passed explicitly nor set as " + + $"DbBase.Context singleton). Set DbBase.Context before calling " + + $"FindRequired<{typeof(T).Name}>."); + + foreach (var e in db.Tracked) + if (e is T candidate && predicate(candidate)) + return candidate; + + throw new InvalidOperationException( + $"FindRequired<{typeof(T).Name}>({predicateToString}) — " + + $"no matching {typeof(T).Name} found in context."); + } } \ No newline at end of file diff --git a/tests/BeforeAfter.Tests/BeforeTests.cs b/tests/BeforeAfter.Tests/BeforeTests.cs index e4ed1ca..763200e 100644 --- a/tests/BeforeAfter.Tests/BeforeTests.cs +++ b/tests/BeforeAfter.Tests/BeforeTests.cs @@ -339,4 +339,122 @@ public class BeforeTests DbBase.Context = null; // cleanup } + + // --------------------------------------------------------------------- + // (17) DbBase.FindRequired finds the matching entity — same result as Find. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_FindRequired_T_finds_matching_entity() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme" }; + var globex = new Client { Name = "Globex" }; + db.Attach(acme); + db.Attach(globex); + + var result = DbBase.FindRequired(c => c.Name == "Globex", db, "name == Globex"); + + Assert.Same(globex, result); + } + + // --------------------------------------------------------------------- + // (18) DbBase.FindRequired throws when no match is found. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_FindRequired_T_throws_when_no_match() + { + var db = new DbContext(); + db.Attach(new Client { Name = "Acme" }); + + var ex = Assert.Throws(() => + DbBase.FindRequired(c => c.Name == "Nobody", db, "name == Nobody")); + + Assert.Contains("Nobody", ex.Message); + Assert.Contains("no matching Client found", ex.Message); + } + + // --------------------------------------------------------------------- + // (19) DbBase.FindRequired throws with a useful message when no context is + // configured (both explicit null and singleton null). + // --------------------------------------------------------------------- + [Fact] + public void DbBase_FindRequired_T_throws_when_no_context_configured() + { + DbBase.Context = null; + + var ex = Assert.Throws(() => + DbBase.FindRequired(_ => true, null, "true")); + + Assert.Contains("no DbContext configured", ex.Message); + Assert.Contains("DbBase.Context", ex.Message); + } + + // --------------------------------------------------------------------- + // (20) Client.FindByNameRequired finds the matching client by name. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_finds_by_name() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme Corp" }; + db.Attach(acme); + + var result = Client.FindByNameRequired("Acme Corp", db); + + Assert.Same(acme, result); + } + + // --------------------------------------------------------------------- + // (21) Client.FindByNameRequired throws when no match — message includes name. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_throws_with_name_when_not_found() + { + var db = new DbContext(); + db.Attach(new Client { Name = "Acme Corp" }); + + var ex = Assert.Throws(() => + Client.FindByNameRequired("Nobody", db)); + + Assert.Contains("Nobody", ex.Message); + Assert.Contains("no matching Client found", ex.Message); + } + + // --------------------------------------------------------------------- + // (22) Client.FindByNameRequired uses Context singleton when no context arg. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_uses_Context_singleton() + { + var db = new DbContext(); + var acme = new Client { Name = "SingletonCo" }; + db.Attach(acme); + + DbBase.Context = db; + try + { + var result = Client.FindByNameRequired("SingletonCo"); + Assert.Same(acme, result); + } + finally + { + DbBase.Context = null; + } + } + + // --------------------------------------------------------------------- + // (23) Client.FindByNameRequired throws with a useful message when the + // singleton is not configured (no arg, no singleton). + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_throws_with_context_hint_when_singleton_null() + { + DbBase.Context = null; + + var ex = Assert.Throws(() => + Client.FindByNameRequired("SomeBody")); + + Assert.Contains("no DbContext configured", ex.Message); + Assert.Contains("DbBase.Context", ex.Message); + } } \ No newline at end of file