diff --git a/README.md b/README.md
index 3c51593..900888d 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,30 @@ Use it only with a new database or when replacing all existing data is intention
[KitchenPC Samples repository](https://github.com/KitchenPC/Samples) for a PostgreSQL initializer
and a small sample dataset.
+Optional DBContext Capabilities
+====
+
+`DBContext` initializes its autocomplete index, ingredient-text parser, and recipe-modeler graph
+by default. Applications that do not use every feature can select only the in-memory capabilities
+they need while retaining ordinary database-backed operations:
+
+```csharp
+var context = DBContext.Configure
+ .Adapter(/* database adapter configuration */)
+ .Capabilities(DBContextCapabilities.IngredientParsing)
+ .Identity(() => AuthIdentity.Anonymous)
+ .Create();
+```
+
+The available flags are `IngredientAutocomplete`, `IngredientParsing`, and `RecipeModeler`.
+`DBContextCapabilities.All` is the default for backward compatibility. Calling an API whose
+capability was not enabled throws `ContextCapabilityNotEnabledException`. Recipe aggregation uses
+the in-memory graph when the modeler is enabled and falls back to loading recipes from the database
+when it is disabled.
+
+See [DBContext capability profiles](docs/context-capabilities.md) for capability requirements and
+sample-data startup and memory measurements.
+
Packages and Releases
====
diff --git a/docs/context-capabilities.md b/docs/context-capabilities.md
new file mode 100644
index 0000000..2a247aa
--- /dev/null
+++ b/docs/context-capabilities.md
@@ -0,0 +1,67 @@
+# DBContext capability profiles
+
+`DBContext` has three independently configurable in-memory capabilities:
+
+| Capability | In-memory data | Required by |
+| --- | --- | --- |
+| `IngredientAutocomplete` | Ingredient-name substring index | `AutocompleteIngredient` |
+| `IngredientParsing` | Ingredient, unit, form, prep-note, anomaly, and numeric grammar indexes | `ParseIngredient` and `ParseIngredientUsage` |
+| `RecipeModeler` | Ratings, recipes, ingredients, tags, and suggestion graph | Recipe modeling APIs |
+
+Database-backed recipe search, recipe details, ingredients, menus, queues, and shopping lists do
+not require these flags. Recipe aggregation uses the modeler graph when available and loads the
+requested recipes from the database when the modeler is disabled.
+
+All capabilities are enabled by default for compatibility. Applications can select a smaller
+profile during configuration:
+
+```csharp
+var context = DBContext.Configure
+ .Adapter(/* database adapter configuration */)
+ .Capabilities(DBContextCapabilities.IngredientParsing)
+ .Identity(() => AuthIdentity.Anonymous)
+ .Create();
+```
+
+Calling an API whose capability was not enabled throws
+`ContextCapabilityNotEnabledException`, whose `Capability` property identifies the missing flag.
+
+## Sample-data measurements
+
+The following measurements compare the previous implementation with each capability profile. They
+were collected on Linux with .NET 10 and PostgreSQL 17 using the KitchenPC sample snapshot (2,707
+ingredients and 30 recipes). Each value is the median of three fresh processes after forced garbage
+collection. Working set includes the runtime, NHibernate, and PostgreSQL client infrastructure.
+
+| Profile | Startup | Managed memory | Working set |
+| --- | ---: | ---: | ---: |
+| Previous implementation | 1,572 ms | 132.9 MiB | 263.1 MiB |
+| `All` | 1,567 ms | 133.0 MiB | 262.5 MiB |
+| Parsing + modeler | 1,072 ms | 71.5 MiB | 185.7 MiB |
+| Parsing only | 993 ms | 71.4 MiB | 180.3 MiB |
+| Autocomplete only | 1,049 ms | 66.6 MiB | 183.4 MiB |
+| Modeler only | 663 ms | 5.2 MiB | 114.0 MiB |
+| Database only | 532 ms | 5.0 MiB | 111.8 MiB |
+
+The parsing-only profile intended for the public sample website reduced startup by approximately
+37%, managed memory by 46%, and process working set by 31% compared with the previous all-feature
+initialization on this dataset. Production-sized recipe data will make the modeler profile more
+expensive than this small snapshot suggests. The private KitchenPC website should be measured
+separately with production-scale data when it adopts the parsing-plus-modeler profile.
+
+An end-to-end PostgreSQL check also used the parsing-only profile to search and load a recipe,
+aggregate its six ingredients through the database fallback, parse `12 eggs`, add the recipe to the
+default shopping list, reload the persisted items, and remove them. The test used a freshly
+provisioned database and removed it afterward.
+
+## Legacy NLP database views
+
+The Core and DB runtime loaders do not query the legacy `FormSynonymsForNLP`,
+`UnitSynonymsForNLP`, `PrepNotesForNLP`, or `AnomaliesForNLP` views. The private Website repository
+still defines those views and grants access to the `Website`, `IngredientCzar`, and `Indexer` roles
+in its database installation script, so they should not be removed without separately auditing
+those external consumers.
+
+The similarly named `shoppingingredientsfornlp` object remains part of the Core persistence model:
+the default schema creates it as a table containing the weight, volume, and unit form pairings used
+by NLP. It is not removed or renamed by the capability work.
diff --git a/src/Core/Context/DBContext.cs b/src/Core/Context/DBContext.cs
index 4886e5b..34e6628 100644
--- a/src/Core/Context/DBContext.cs
+++ b/src/Core/Context/DBContext.cs
@@ -30,7 +30,8 @@ private AsUser(
ModelerProxy modeler,
Parser parser,
IDBAdapter adapter,
- AuthIdentity identity
+ AuthIdentity identity,
+ DBContextCapabilities capabilities
)
{
this.ingParser = ingParser;
@@ -39,10 +40,18 @@ AuthIdentity identity
this.Adapter = adapter;
this.Identity = identity;
this.GetIdentity = () => identity;
+ this.Capabilities = capabilities;
}
public static AsUser Clone(DBContext context, AuthIdentity identity) =>
- new AsUser(context.ingParser, context.modeler, context.parser, context.Adapter, identity);
+ new AsUser(
+ context.ingParser,
+ context.modeler,
+ context.parser,
+ context.Adapter,
+ identity,
+ context.Capabilities
+ );
}
private static readonly ReaderWriterLockSlim InitLock = new();
@@ -52,6 +61,9 @@ public static AsUser Clone(DBContext context, AuthIdentity identity) =>
protected ModelerProxy modeler;
protected Parser parser;
+ /// Gets the optional in-memory capabilities configured for this context.
+ public DBContextCapabilities Capabilities { get; internal set; } = DBContextCapabilities.All;
+
/// Gets or sets the IDBAdapter used to directly talk with the database.
public IDBAdapter Adapter { get; set; }
@@ -79,6 +91,7 @@ public virtual Parser Parser
{
get
{
+ EnsureCapabilityEnabled(DBContextCapabilities.IngredientParsing);
using (InitLock.ReadLock())
{
return parser;
@@ -90,6 +103,7 @@ public virtual ModelerProxy ModelerProxy
{
get
{
+ EnsureCapabilityEnabled(DBContextCapabilities.RecipeModeler);
using (InitLock.ReadLock())
{
return modeler;
@@ -110,7 +124,14 @@ public virtual ModelerProxy ModelerProxy
public virtual QueueAction Queue => new(this);
/// Provides the ability to fluently work with the recipe modeler.
- public virtual ModelerAction Modeler => new(this);
+ public virtual ModelerAction Modeler
+ {
+ get
+ {
+ EnsureCapabilityEnabled(DBContextCapabilities.RecipeModeler);
+ return new(this);
+ }
+ }
///
/// Initializes the context and loads necessary data into memory through the configured database adapter.
@@ -128,41 +149,47 @@ public virtual void Initialize()
// Initialize NHibernate session
Adapter.Initialize(this);
- new Thread(
- delegate()
+ using (InitLock.WriteLock())
+ {
+ if (HasCapability(DBContextCapabilities.IngredientAutocomplete))
{
- using (InitLock.WriteLock())
- {
- // Initialize ingredient parser
- ingParser = new IngredientParser();
- var ingredientIndex = Adapter.LoadIngredientsForIndex();
- ingParser.CreateIndex(ingredientIndex);
-
- // Initialize modeler
- modeler = new ModelerProxy(this);
- modeler.LoadSnapshot();
-
- // Initialize natural language parsing
- IngredientSynonyms.InitIndex(Adapter.IngredientLoader);
- UnitSynonyms.InitIndex(Adapter.UnitLoader);
- FormSynonyms.InitIndex(Adapter.FormLoader);
- PrepNotes.InitIndex(Adapter.PrepLoader);
- Anomalies.InitIndex(Adapter.AnomalyLoader);
- NumericVocab.InitIndex();
-
- parser = new Parser();
- LoadTemplates();
- }
+ ingParser = new IngredientParser();
+ var ingredientIndex = Adapter.LoadIngredientsForIndex();
+ ingParser.CreateIndex(ingredientIndex);
}
- ).Start();
- Thread.Sleep(500); // Provides time for initialize thread to start and acquire InitLock
+ if (HasCapability(DBContextCapabilities.RecipeModeler))
+ {
+ modeler = new ModelerProxy(this);
+ modeler.LoadSnapshot();
+ }
+
+ if (HasCapability(DBContextCapabilities.IngredientParsing))
+ {
+ IngredientSynonyms.InitIndex(Adapter.IngredientLoader);
+ UnitSynonyms.InitIndex(Adapter.UnitLoader);
+ FormSynonyms.InitIndex(Adapter.FormLoader);
+ PrepNotes.InitIndex(Adapter.PrepLoader);
+ Anomalies.InitIndex(Adapter.AnomalyLoader);
+ NumericVocab.InitIndex();
+
+ parser = new Parser();
+ LoadTemplates();
+ }
+ }
}
///
/// Returns an object able to load modeling information. This will be called automatically when the modeler is initialized.
///
- public virtual IModelerLoader ModelerLoader => new DBModelerLoader(Adapter);
+ public virtual IModelerLoader ModelerLoader
+ {
+ get
+ {
+ EnsureCapabilityEnabled(DBContextCapabilities.RecipeModeler);
+ return new DBModelerLoader(Adapter);
+ }
+ }
///
/// Takes part of an ingredient name and returns possible matches, useful for autocomplete UIs.
@@ -171,6 +198,7 @@ public virtual void Initialize()
/// An enumeration of IngredientNode objects describing possible matches and their IDs.
public virtual IEnumerable AutocompleteIngredient(string query)
{
+ EnsureCapabilityEnabled(DBContextCapabilities.IngredientAutocomplete);
using (InitLock.ReadLock())
{
return ingParser.MatchIngredient(query);
@@ -386,6 +414,21 @@ public virtual IList AggregateIngredients(params Ingredie
/// A list of IngredientAggregation objects, one per unique ingredient in the set of recipes
public virtual IList AggregateRecipes(params Guid[] recipeIds)
{
+ if (recipeIds == null)
+ throw new ArgumentNullException(nameof(recipeIds));
+ if (recipeIds.Length == 0)
+ return Array.Empty();
+
+ if (!HasCapability(DBContextCapabilities.RecipeModeler))
+ {
+ var recipes = Adapter.ReadRecipes(Identity, recipeIds, ReadRecipeOptions.None);
+ return AggregateRecipeUsages(
+ recipes
+ .SelectMany(recipe => recipe.Ingredients)
+ .Where(usage => usage.Ingredient.Id != ShoppingList.GUID_WATER)
+ );
+ }
+
using (InitLock.ReadLock())
{
var ings = new Dictionary(); //List of all ingredients and total usage
@@ -400,7 +443,9 @@ public virtual IList AggregateRecipes(params Guid[] recip
foreach (var usage in rNode.Ingredients)
{
var ingId = usage.Ingredient.IngredientId;
- var ingName = ingParser.GetIngredientById(ingId);
+ var ingName = usage.Ingredient.DisplayName;
+ if (String.IsNullOrWhiteSpace(ingName))
+ ingName = Adapter.ReadIngredient(ingId)?.Name;
var ing = new Ingredient(ingId, ingName);
ing.ConversionType = usage.Ingredient.ConvType;
@@ -427,6 +472,38 @@ public virtual IList AggregateRecipes(params Guid[] recip
}
}
+ private IList AggregateRecipeUsages(
+ IEnumerable usages
+ ) =>
+ usages
+ .GroupBy(usage => usage.Ingredient.Id)
+ .Select(group =>
+ {
+ var aggregation = new IngredientAggregation(group.First().Ingredient);
+ foreach (var usage in group)
+ {
+ if (usage.Amount == null)
+ {
+ aggregation.Amount = null;
+ break;
+ }
+
+ aggregation.AddUsage(usage);
+ }
+
+ return aggregation;
+ })
+ .ToArray();
+
+ private bool HasCapability(DBContextCapabilities capability) =>
+ (Capabilities & capability) == capability;
+
+ private void EnsureCapabilityEnabled(DBContextCapabilities capability)
+ {
+ if (!HasCapability(capability))
+ throw new ContextCapabilityNotEnabledException(capability);
+ }
+
///
/// Returns the specified set of menus owned by the current user.
///
diff --git a/src/Core/Context/DBContextBuilder.cs b/src/Core/Context/DBContextBuilder.cs
index 87f2daa..ea8c137 100644
--- a/src/Core/Context/DBContextBuilder.cs
+++ b/src/Core/Context/DBContextBuilder.cs
@@ -24,6 +24,19 @@ public DBContextBuilder Identity(Func getIdentity)
return this;
}
+ ///
+ /// Selects the optional in-memory indexes initialized by this context. All capabilities are
+ /// enabled by default for backward compatibility.
+ ///
+ public DBContextBuilder Capabilities(DBContextCapabilities capabilities)
+ {
+ if ((capabilities & ~DBContextCapabilities.All) != 0)
+ throw new ArgumentOutOfRangeException(nameof(capabilities));
+
+ context.Capabilities = capabilities;
+ return this;
+ }
+
public DBContext Create()
{
return context;
diff --git a/src/Core/Context/DBContextCapabilities.cs b/src/Core/Context/DBContextCapabilities.cs
new file mode 100644
index 0000000..5966031
--- /dev/null
+++ b/src/Core/Context/DBContextCapabilities.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace KitchenPC.Core.Context;
+
+///
+/// Controls which optional in-memory indexes a initializes.
+/// Database-backed recipe, ingredient, menu, queue, and shopping-list operations remain available
+/// regardless of the selected capabilities.
+///
+[Flags]
+public enum DBContextCapabilities
+{
+ /// Initialize only the configured database adapter.
+ None = 0,
+
+ /// Build the substring index used by ingredient autocomplete.
+ IngredientAutocomplete = 1,
+
+ /// Build the grammar and synonym indexes used to parse ingredient text.
+ IngredientParsing = 2,
+
+ /// Load the in-memory recipe graph used by the recipe modeler.
+ RecipeModeler = 4,
+
+ /// Initialize every optional capability. This is the default for compatibility.
+ All = IngredientAutocomplete | IngredientParsing | RecipeModeler,
+}
diff --git a/src/Core/Context/StaticModelerLoader.cs b/src/Core/Context/StaticModelerLoader.cs
index 27fa873..06ce6df 100644
--- a/src/Core/Context/StaticModelerLoader.cs
+++ b/src/Core/Context/StaticModelerLoader.cs
@@ -62,7 +62,8 @@ select IngredientBinding.Create(
i.Value.UnitWeight,
f.Value.UnitType,
f.Value.FormAmount,
- f.Value.FormUnit
+ f.Value.FormUnit,
+ i.Value.DisplayName
)
);
diff --git a/src/Core/Exceptions.cs b/src/Core/Exceptions.cs
index e186005..b0b2214 100644
--- a/src/Core/Exceptions.cs
+++ b/src/Core/Exceptions.cs
@@ -1,4 +1,5 @@
using System;
+using KitchenPC.Core.Context;
using KitchenPC.Core.Ingredients;
using KitchenPC.Core.NLP;
@@ -20,6 +21,17 @@ public InvalidConfigurationException(string message)
: base(message) { }
}
+public class ContextCapabilityNotEnabledException : KPCException
+{
+ public ContextCapabilityNotEnabledException(DBContextCapabilities capability)
+ : base($"The DBContext capability '{capability}' is not enabled.")
+ {
+ Capability = capability;
+ }
+
+ public DBContextCapabilities Capability { get; }
+}
+
public class DataStoreException : KPCException
{
public DataStoreException(string message)
diff --git a/src/Core/Modeler/DBSnapshot.cs b/src/Core/Modeler/DBSnapshot.cs
index 346136a..d4108ff 100644
--- a/src/Core/Modeler/DBSnapshot.cs
+++ b/src/Core/Modeler/DBSnapshot.cs
@@ -137,6 +137,7 @@ from o in loader.LoadRecipeGraph()
ingNode = new IngredientNode()
{
IngredientId = ingid,
+ DisplayName = o.IngredientName,
RecipesByTag = nodes,
ConvType = convType,
}
diff --git a/src/Core/Modeler/IngredientBinding.cs b/src/Core/Modeler/IngredientBinding.cs
index 78a7f23..0f2d4fb 100644
--- a/src/Core/Modeler/IngredientBinding.cs
+++ b/src/Core/Modeler/IngredientBinding.cs
@@ -7,6 +7,7 @@ public struct IngredientBinding
{
public Guid RecipeId { get; set; }
public Guid IngredientId { get; set; }
+ public String IngredientName { get; set; }
public Single? Qty { get; set; }
public Units Unit { get; set; }
@@ -20,6 +21,31 @@ public static IngredientBinding Create(
Units? formUnit,
Single? equivAmount,
Units? equivUnit
+ ) =>
+ Create(
+ ingId,
+ recipeId,
+ qty,
+ usageUnit,
+ convType,
+ unitWeight,
+ formUnit,
+ equivAmount,
+ equivUnit,
+ null
+ );
+
+ public static IngredientBinding Create(
+ Guid ingId,
+ Guid recipeId,
+ Single? qty,
+ Units usageUnit,
+ UnitType convType,
+ Int32 unitWeight,
+ Units? formUnit,
+ Single? equivAmount,
+ Units? equivUnit,
+ String ingredientName
)
{
var rawUnit = Core.Unit.GetDefaultUnitType(convType);
@@ -35,7 +61,7 @@ public static IngredientBinding Create(
if (!formUnit.HasValue || !equivAmount.HasValue || !equivUnit.HasValue)
{
qty = null;
- return CreateBinding(ingId, recipeId, qty, rawUnit);
+ return CreateBinding(ingId, recipeId, qty, rawUnit, ingredientName);
}
var ing = new Ingredient
@@ -71,19 +97,21 @@ public static IngredientBinding Create(
}
}
- return CreateBinding(ingId, recipeId, qty, rawUnit);
+ return CreateBinding(ingId, recipeId, qty, rawUnit, ingredientName);
}
private static IngredientBinding CreateBinding(
Guid ingredientId,
Guid recipeId,
Single? quantity,
- Units unit
+ Units unit,
+ String ingredientName
) =>
new IngredientBinding
{
RecipeId = recipeId,
IngredientId = ingredientId,
+ IngredientName = ingredientName,
Qty = quantity.HasValue ? (float?)Math.Round(quantity.Value, 3) : null,
Unit = unit,
};
diff --git a/src/Core/Modeler/IngredientNode.cs b/src/Core/Modeler/IngredientNode.cs
index ff2219d..1838ebf 100644
--- a/src/Core/Modeler/IngredientNode.cs
+++ b/src/Core/Modeler/IngredientNode.cs
@@ -10,6 +10,7 @@ public sealed class IngredientNode
public Int32 Key; //Interally, ingredients will have numeric keys for faster hashing
public Guid IngredientId; //KPC Shopping Ingredient ID
+ public String DisplayName; //Ingredient display name used outside the autocomplete index
public UnitType ConvType; //Conversion type for this ingredient
public IEnumerable[] RecipesByTag; //Recipes that use this ingredient (does not include Hidden recipes)
public RecipeTags AvailableTags; //Which indices in RecipesByTag are not null
diff --git a/src/DB/DatabaseAdapter.cs b/src/DB/DatabaseAdapter.cs
index 2c58a6b..278f556 100644
--- a/src/DB/DatabaseAdapter.cs
+++ b/src/DB/DatabaseAdapter.cs
@@ -203,7 +203,8 @@ public IEnumerable LoadIngredientGraph()
p => joinIng.UnitWeight,
p => joinForm.UnitType,
p => joinForm.FormAmount,
- p => joinForm.FormUnit
+ p => joinForm.FormUnit,
+ p => joinIng.DisplayName
)
.TransformUsing(IngredientGraphTransformer.Create())
.List();
diff --git a/src/DB/NLP/IngredientGraphTransformer.cs b/src/DB/NLP/IngredientGraphTransformer.cs
index fc73491..51cdcce 100644
--- a/src/DB/NLP/IngredientGraphTransformer.cs
+++ b/src/DB/NLP/IngredientGraphTransformer.cs
@@ -24,6 +24,7 @@ public object TransformTuple(object[] tuple, string[] aliases) =>
(int)tuple[5], //I.UnitWeight
tuple[6] is Units formUnit ? formUnit : null, //F.UnitType
tuple[7] is float formAmount ? formAmount : null, //F.FormAmount
- tuple[8] is Units formAmountUnit ? formAmountUnit : null //F.FormUnit
+ tuple[8] is Units formAmountUnit ? formAmountUnit : null, //F.FormUnit
+ (String)tuple[9] //I.DisplayName
);
}
diff --git a/src/UnitTests/DBContextCapabilities.cs b/src/UnitTests/DBContextCapabilities.cs
new file mode 100644
index 0000000..a5fbb89
--- /dev/null
+++ b/src/UnitTests/DBContextCapabilities.cs
@@ -0,0 +1,318 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using KitchenPC.Core;
+using KitchenPC.Core.Context;
+using KitchenPC.Core.Ingredients;
+using KitchenPC.Core.Menus;
+using KitchenPC.Core.Modeler;
+using KitchenPC.Core.NLP;
+using KitchenPC.Core.Provisioning;
+using KitchenPC.Core.Recipes;
+using KitchenPC.Core.ShoppingLists;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using CoreIngredientUsage = KitchenPC.Core.Ingredients.IngredientUsage;
+using NlpIngredientNode = KitchenPC.Core.NLP.IngredientNode;
+
+namespace KitchenPC.UnitTests;
+
+[TestClass]
+[DoNotParallelize]
+public class DBContextCapabilitiesTest
+{
+ [TestMethod]
+ public void DefaultsToAllCapabilities()
+ {
+ var context = DBContext.Configure.Create();
+
+ Assert.AreEqual(DBContextCapabilities.All, context.Capabilities);
+ }
+
+ [TestMethod]
+ public void CapabilitiesArePreservedByUserContext()
+ {
+ var context = DBContext
+ .Configure.Capabilities(DBContextCapabilities.IngredientParsing)
+ .Identity(() => AuthIdentity.Anonymous)
+ .Create();
+
+ var userContext = (DBContext)
+ context.AsUserContext(new AuthIdentity(Guid.NewGuid(), "Sample user"));
+
+ Assert.AreEqual(DBContextCapabilities.IngredientParsing, userContext.Capabilities);
+ }
+
+ [DataTestMethod]
+ [DataRow(DBContextCapabilities.None, false, false, false)]
+ [DataRow(DBContextCapabilities.IngredientAutocomplete, true, false, false)]
+ [DataRow(DBContextCapabilities.IngredientParsing, false, true, false)]
+ [DataRow(DBContextCapabilities.RecipeModeler, false, false, true)]
+ [DataRow(
+ DBContextCapabilities.IngredientAutocomplete | DBContextCapabilities.IngredientParsing,
+ true,
+ true,
+ false
+ )]
+ [DataRow(
+ DBContextCapabilities.IngredientAutocomplete | DBContextCapabilities.RecipeModeler,
+ true,
+ false,
+ true
+ )]
+ [DataRow(
+ DBContextCapabilities.IngredientParsing | DBContextCapabilities.RecipeModeler,
+ false,
+ true,
+ true
+ )]
+ [DataRow(DBContextCapabilities.All, true, true, true)]
+ public void InitializesOnlySelectedCapabilities(
+ DBContextCapabilities capabilities,
+ bool autocomplete,
+ bool parsing,
+ bool modeler
+ )
+ {
+ var (context, adapter) = CreateContext(capabilities);
+
+ context.Initialize();
+
+ Assert.AreEqual(1, adapter.CallCount("Initialize"));
+ Assert.AreEqual(autocomplete ? 1 : 0, adapter.CallCount("LoadIngredientsForIndex"));
+ Assert.AreEqual(modeler ? 1 : 0, adapter.CallCount("LoadRecipeGraph"));
+ Assert.AreEqual(modeler ? 1 : 0, adapter.CallCount("LoadIngredientGraph"));
+ Assert.AreEqual(modeler ? 1 : 0, adapter.CallCount("LoadRatingGraph"));
+ Assert.AreEqual(parsing ? 1 : 0, adapter.CallCount("get_IngredientLoader"));
+ Assert.AreEqual(parsing ? 1 : 0, adapter.CallCount("get_UnitLoader"));
+ Assert.AreEqual(parsing ? 1 : 0, adapter.CallCount("get_FormLoader"));
+ Assert.AreEqual(parsing ? 1 : 0, adapter.CallCount("get_PrepLoader"));
+ Assert.AreEqual(parsing ? 1 : 0, adapter.CallCount("get_AnomalyLoader"));
+ }
+
+ [TestMethod]
+ public void DisabledCapabilitiesThrowClearErrors()
+ {
+ var (context, _) = CreateContext(DBContextCapabilities.None);
+ context.Initialize();
+
+ AssertCapabilityError(
+ DBContextCapabilities.IngredientAutocomplete,
+ () => context.AutocompleteIngredient("egg")
+ );
+ AssertCapabilityError(
+ DBContextCapabilities.IngredientParsing,
+ () => context.ParseIngredientUsage("12 eggs")
+ );
+ AssertCapabilityError(
+ DBContextCapabilities.RecipeModeler,
+ () =>
+ {
+ _ = context.ModelerProxy;
+ }
+ );
+ AssertCapabilityError(
+ DBContextCapabilities.RecipeModeler,
+ () =>
+ {
+ _ = context.Modeler;
+ }
+ );
+ }
+
+ [TestMethod]
+ public void AggregatesRecipesFromDatabaseWhenModelerIsDisabled()
+ {
+ var ingredient = new Ingredient(Guid.NewGuid(), "eggs") { ConversionType = UnitType.Unit };
+ var recipe = new Recipe(Guid.NewGuid(), "Eggs", null, null)
+ {
+ Ingredients =
+ [
+ new CoreIngredientUsage
+ {
+ Ingredient = ingredient,
+ Amount = new Amount(12, Units.Unit),
+ },
+ ],
+ };
+ var (context, adapter) = CreateContext(DBContextCapabilities.IngredientParsing);
+ adapter.Recipes = [recipe];
+ context.Initialize();
+
+ var result = context.AggregateRecipes(recipe.Id).Single();
+
+ Assert.AreEqual("eggs", result.Ingredient.Name);
+ Assert.AreEqual(new Amount(12, Units.Unit), result.Amount);
+ Assert.AreEqual(1, adapter.CallCount("ReadRecipes"));
+ Assert.AreEqual(0, adapter.CallCount("LoadRecipeGraph"));
+ }
+
+ [TestMethod]
+ public void DatabaseRecipeAggregationNormalizesIngredientForms()
+ {
+ var ingredient = new Ingredient(Guid.NewGuid(), "flour") { ConversionType = UnitType.Weight };
+ var cupForm = new IngredientForm
+ {
+ IngredientId = ingredient.Id,
+ FormUnitType = Units.Cup,
+ FormAmount = new Amount(4, Units.Gram),
+ };
+ var recipe = new Recipe(Guid.NewGuid(), "Bread", null, null)
+ {
+ Ingredients =
+ [
+ new CoreIngredientUsage
+ {
+ Ingredient = ingredient,
+ Form = cupForm,
+ Amount = new Amount(2, Units.Cup),
+ },
+ ],
+ };
+ var (context, adapter) = CreateContext(DBContextCapabilities.None);
+ adapter.Recipes = [recipe];
+ context.Initialize();
+
+ var result = context.AggregateRecipes(recipe.Id).Single();
+
+ Assert.AreEqual(UnitConverter.Convert(new Amount(8, Units.Gram), Units.Ounce), result.Amount);
+ }
+
+ [TestMethod]
+ public void DatabaseRecipeAggregationExcludesWater()
+ {
+ var water = new Ingredient(ShoppingList.GUID_WATER, "water")
+ {
+ ConversionType = UnitType.Volume,
+ };
+ var recipe = new Recipe(Guid.NewGuid(), "Tea", null, null)
+ {
+ Ingredients =
+ [
+ new CoreIngredientUsage { Ingredient = water, Amount = new Amount(1, Units.Cup) },
+ ],
+ };
+ var (context, adapter) = CreateContext(DBContextCapabilities.None);
+ adapter.Recipes = [recipe];
+ context.Initialize();
+
+ var result = context.AggregateRecipes(recipe.Id);
+
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [TestMethod]
+ public void ModelerAggregationDoesNotRequireAutocompleteIndex()
+ {
+ var recipeId = Guid.NewGuid();
+ var ingredientId = Guid.NewGuid();
+ var (context, adapter) = CreateContext(DBContextCapabilities.RecipeModeler);
+ adapter.RecipeBindings = [new RecipeBinding { Id = recipeId, Tags = RecipeTags.None }];
+ adapter.IngredientBindings =
+ [
+ IngredientBinding.Create(
+ ingredientId,
+ recipeId,
+ 2,
+ Units.Unit,
+ UnitType.Unit,
+ 0,
+ null,
+ null,
+ null,
+ "eggs"
+ ),
+ ];
+ context.Initialize();
+
+ var result = context.AggregateRecipes(recipeId).Single();
+
+ Assert.AreEqual("eggs", result.Ingredient.Name);
+ Assert.AreEqual(new Amount(2, Units.Unit), result.Amount);
+ Assert.AreEqual(0, adapter.CallCount("LoadIngredientsForIndex"));
+ Assert.AreEqual(0, adapter.CallCount("ReadRecipes"));
+ }
+
+ [TestMethod]
+ public void RejectsUnknownCapabilities()
+ {
+ Assert.ThrowsException(() =>
+ DBContext.Configure.Capabilities((DBContextCapabilities)8)
+ );
+ }
+
+ private static void AssertCapabilityError(DBContextCapabilities expected, Action action)
+ {
+ var exception = Assert.ThrowsException(action);
+ Assert.AreEqual(expected, exception.Capability);
+ }
+
+ private static (DBContext Context, TrackingAdapter Adapter) CreateContext(
+ DBContextCapabilities capabilities
+ )
+ {
+ var adapter = DispatchProxy.Create();
+ var tracker = (TrackingAdapter)(object)adapter;
+ var context = DBContext
+ .Configure.Adapter(new AdapterBuilder(adapter))
+ .Capabilities(capabilities)
+ .Identity(() => AuthIdentity.Anonymous)
+ .Create();
+
+ return (context, tracker);
+ }
+
+ private sealed class AdapterBuilder : IConfigurationBuilder
+ {
+ private readonly IDBAdapter adapter;
+
+ public AdapterBuilder(IDBAdapter adapter)
+ {
+ this.adapter = adapter;
+ }
+
+ public IDBAdapter Create() => adapter;
+ }
+}
+
+public class TrackingAdapter : DispatchProxy
+{
+ private readonly List calls = [];
+
+ public Recipe[] Recipes { get; set; } = [];
+ public RecipeBinding[] RecipeBindings { get; set; } = [];
+ public IngredientBinding[] IngredientBindings { get; set; } = [];
+
+ public int CallCount(string name) => calls.Count(call => call == name);
+
+ protected override object Invoke(MethodInfo targetMethod, object[] args)
+ {
+ calls.Add(targetMethod.Name);
+
+ return targetMethod.Name switch
+ {
+ "Initialize" => null,
+ "LoadIngredientsForIndex" => Array.Empty(),
+ "LoadRecipeGraph" => RecipeBindings,
+ "LoadIngredientGraph" => IngredientBindings,
+ "LoadRatingGraph" => Array.Empty(),
+ "get_IngredientLoader" => new EmptySynonymLoader(),
+ "get_UnitLoader" => new EmptySynonymLoader(),
+ "get_FormLoader" => new EmptySynonymLoader(),
+ "get_PrepLoader" => new EmptySynonymLoader(),
+ "get_AnomalyLoader" => new EmptySynonymLoader(),
+ "ReadRecipes" => Recipes,
+ "Export" => new DataStore(),
+ "Import" or "InitializeStore" => null,
+ _ => throw new NotSupportedException(targetMethod.Name),
+ };
+ }
+}
+
+internal sealed class EmptySynonymLoader : ISynonymLoader
+{
+ public IEnumerable LoadSynonyms() => Array.Empty();
+
+ public Pairings LoadFormPairings() => new();
+}
diff --git a/src/UnitTests/IngredientBinding.cs b/src/UnitTests/IngredientBinding.cs
index a5958f6..32e3fc1 100644
--- a/src/UnitTests/IngredientBinding.cs
+++ b/src/UnitTests/IngredientBinding.cs
@@ -20,11 +20,13 @@ public void MissingFormPreservesDirectlyConvertibleIngredient()
0,
null,
null,
- null
+ null,
+ "flour"
);
Assert.AreEqual(16f, binding.Qty);
Assert.AreEqual(Units.Ounce, binding.Unit);
+ Assert.AreEqual("flour", binding.IngredientName);
}
[TestMethod]