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]