- DbBase.FindRequired / Client.FindByNameRequired now throw the domain-specific ObjectNotFoundException (NHibernate lineage; Rails equivalent: ActiveRecord::RecordNotFound) on a lookup miss; the no-context case stays InvalidOperationException (configuration error) - Console demo catches the new type; tests (18)/(21) assert it, (19)/(23) keep InvalidOperationException with rationale comments - scripts/run-console.sh delegates to dotnet.sh; run-console.ps1 is the Windows/PowerShell counterpart (mise-first, repo-root cd) Yak: objectnotfoundexception-run-console-wrappers-3p8b
128 lines
5.9 KiB
C#
128 lines
5.9 KiB
C#
namespace Before;
|
|
|
|
/// <summary>
|
|
/// Base class for every DB-backed domain object. Fakes the active-record part
|
|
/// of EF: each entity carries its own <see cref="Id"/> and, once it has been
|
|
/// created/saved through a context, a back-reference to that context.
|
|
///
|
|
/// <see cref="DbContext"/> is the dependency this base class leaks. The
|
|
/// "after" situation (Yak 02) removes this inheritance entirely — domain
|
|
/// objects become plain POCOs that know nothing about a DbContext.
|
|
/// </summary>
|
|
public class DbBase
|
|
{
|
|
// -----------------------------------------------------------------
|
|
// Static singleton: an ActiveRecord-style shared context that any
|
|
// entity can reach without passing it through method parameters.
|
|
// -----------------------------------------------------------------
|
|
/// <summary>
|
|
/// Shared (singleton) <see cref="DbContext" /> accessible from every
|
|
/// entity via its base type. Set once at application startup so that
|
|
/// entity methods can call <c>DbBase.Context!</c> instead of carrying
|
|
/// a context reference.
|
|
///
|
|
/// This is yet another leak: domain objects depend on the persistence
|
|
/// layer at the *type* level, not just the instance level.
|
|
/// </summary>
|
|
public static DbContext? Context { get; set; }
|
|
|
|
/// <summary>
|
|
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
|
|
/// <see cref="DbContext.Save"/> assigns a real id, faking EF's identity
|
|
/// generation.
|
|
/// </summary>
|
|
public Guid Id { get; set; }
|
|
|
|
/// <summary>
|
|
/// Active-record back-reference: "I know which context created me". This
|
|
/// is the leak the exercise exposes — from a plain domain object you can
|
|
/// reach straight into the persistence layer. <c>internal set</c> because
|
|
/// only the owning <see cref="DbContext"/> may (re)assign it; consumers
|
|
/// (including the WebApp, and the tests) can only read it.
|
|
/// </summary>
|
|
public DbContext? DbContext { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// Generic ActiveRecord-style finder: walks the <paramref name="db"/
|
|
/// />'s tracked entities, looks for the first whose runtime type matches
|
|
/// <typeparamref name="T"/> and satisfies <paramref name="predicate"/>
|
|
///.
|
|
///
|
|
/// This static generic rides the <c>DbContext</c> leak just like
|
|
/// <see cref="Client.FindByName"/>, but works for any <see cref="DbBase"
|
|
/// /> subtype without each entity needing its own hand-written finder.
|
|
/// </summary>
|
|
/// <typeparam name="T">Entity type to find (must derive from <see cref="DbBase"/>).
|
|
/// </typeparam>
|
|
/// <param name="predicate">Filter applied to candidates of type <typeparamref name="T"/>.
|
|
/// </param>
|
|
/// <param name="db">The context whose tracked entities to search.
|
|
/// When <c>null</c>, falls back to the <see cref="Context"/> singleton —
|
|
/// so a caller only passes a context explicitly when it must differ from
|
|
/// the ambient one.
|
|
/// </param>
|
|
/// <returns>The first matching entity, or <c>null</c> when no match.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// Note the (deliberate, ActiveRecord-style) ambiguity this still leaves:
|
|
/// a <c>null</c> result means "no match — or no context configured at
|
|
/// all". The caller cannot distinguish the two; that is part of the cost
|
|
/// of the static-singleton leak this exercise illustrates.
|
|
/// </remarks>
|
|
public static T? Find<T>(Predicate<T> predicate, DbContext? db) where T : DbBase
|
|
{
|
|
db ??= Context;
|
|
|
|
if (db is null)
|
|
return default;
|
|
|
|
foreach (var e in db.Tracked)
|
|
if (e is T candidate && predicate(candidate))
|
|
return candidate;
|
|
|
|
return default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throwing variant of <see cref="Find{T}(System.Predicate{T},DbContext?)"/>. Finds the first entity
|
|
/// whose runtime type matches <typeparamref name="T"/> and satisfies
|
|
/// <paramref name="predicate"/>. Throws <see cref="ObjectNotFoundException"/>
|
|
/// when no match is found, and <see cref="InvalidOperationException"/> when no
|
|
/// <see cref="Context"/> singleton is configured (a configuration error, not a lookup miss).
|
|
///
|
|
/// The exception message includes <paramref name="predicateToString"/> so
|
|
/// callers can debug which lookup failed.
|
|
/// </summary>
|
|
/// <param name="predicate">Filter applied to candidates of type <typeparamref name="T"/>.</param>
|
|
/// <param name="db">The context whose tracked entities to search. When <c>null</c>, falls back
|
|
/// to the <see cref="Context"/> singleton.</param>
|
|
/// <param name="predicateToString">A human-readable description of the predicate, used in the
|
|
/// exception message when the search fails.</param>
|
|
/// <returns>The first matching entity.</returns>
|
|
/// <exception cref="ObjectNotFoundException">
|
|
/// Thrown when no entity matches <paramref name="predicate"/>.
|
|
/// </exception>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// Thrown when no context is available (neither passed explicitly nor set as the singleton).
|
|
/// </exception>
|
|
public static T FindRequired<T>(Predicate<T> 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 ObjectNotFoundException(
|
|
$"FindRequired<{typeof(T).Name}>({predicateToString}) — " +
|
|
$"no matching {typeof(T).Name} found in context.");
|
|
}
|
|
} |