diff --git a/docs/DataModel.md b/docs/DataModel.md index 97469f3..f09046d 100644 --- a/docs/DataModel.md +++ b/docs/DataModel.md @@ -1,6 +1,14 @@ # Data Model -This document briefly describes the data model for features/experiments used in Excos. +## General feature model + +A **feature** has a name and a set of **variants**, each of which has a unique id, a set of filters associated with it, a priority (in case multiple variants are resolved for one feature) and a set of settings. + +Filters can be applied over evaluation context. Allocation of experiments can also be implemented as a special filter. + +## Excos Configuration based provider + +This document briefly describes the data model for features/experiments used in Excos Configuration based provider. _The Excos.Option beta changes made the abstraction much looser to allow for more provider led customizations._ A **unit** is either a user ID or a session ID, or some other semi-persistent identifier for which we will compute experiment allocation. diff --git a/docs/Extensibility.md b/docs/Extensibility.md index 9164d1a..64a09a0 100644 --- a/docs/Extensibility.md +++ b/docs/Extensibility.md @@ -4,6 +4,6 @@ As I was writing the base code for the library I kept in mind it needs to be ext You can integrate with different platforms and SDKs providing experimentation via the `IFeatureProvider` interface, while providing binding to options objects with `IConfigureOptions`. -You can create custom variant overrides (depending on the platform you run the code on) via the `IFeatureVariantOverride` interface. +~~You can create custom variant overrides (depending on the platform you run the code on) via the `IFeatureVariantOverride` interface.~~ _Deprecated in beta update_. You can create custom filters for features and variants with the `IFilteringCondition` and plug them into the configuration based provider with the `IFeatureFilterParser`. diff --git a/docs/GrowthBookGuide.md b/docs/GrowthBookGuide.md index aed7029..e6826d6 100644 --- a/docs/GrowthBookGuide.md +++ b/docs/GrowthBookGuide.md @@ -108,8 +108,10 @@ Optionally, before integrating with Growthbook, you can test the Excos feature d ```csharp builder.Services.BuildFeature("TestRollout") - .Configure(feature => feature.AllocationUnit = nameof(StoreOptionsContext.SessionId)) - .Rollout(75 /*percent*/, (options, _) => options.ItemsPerPage = 20) + .Rollout( + 75 /*percent*/, + (options, _) => options.ItemsPerPage = 20, + allocationUnit: nameof(StoreOptionsContext.SessionId)) .Save(); ``` @@ -209,10 +211,6 @@ public class ExperimentationAssignment [Key] public Guid SessionId { get; set; } - [Required] - [Column(TypeName = "nvarchar(128)")] - public string ExperimentName { get; set; } - [Required] [Column(TypeName = "nvarchar(128)")] public string VariantId { get; set; } @@ -240,24 +238,34 @@ With that in place we can implement our `ExperimentationService` class which bas public class ExperimentationService : IExperimentationService { private readonly ExperimentationContext _dbContext; - public ExperimentationService(ExperimentationContext dbContext) => _dbContext = dbContext; + private readonly /*Excos.Options.*/IFeatureEvaluation _featureEvaluation; + + public ExperimentationService(ExperimentationContext dbContext, IFeatureEvaluation featureEvaluation) + { + _dbContext = dbContext; + _featureEvaluation = featureEvaluation; + } - public async Task SaveExperimentAssignmentAsync(Guid sessionId, string experimentName, string variantId) + public async Task SaveExperimentAssignmentAsync(StoreOptionsContext context) { + // check if we already have an assignment for this session var assignment = await _dbContext.Assignments - .Where(a => a.SessionId == sessionId) + .Where(a => a.SessionId == context.SessionId) .FirstOrDefaultAsync(); if (assignment == null) { - assignment = new ExperimentationAssignment + await foreach (var variant in _featureEvaluation.EvaluateFeaturesAsync(context, default(CancellationToken))) { - SessionId = sessionId, - ExperimentName = experimentName, - VariantId = variantId - }; + assignment = new ExperimentationAssignment + { + SessionId = context.SessionId, + VariantId = variant.Id + }; + + _dbContext.Assignments.Add(assignment); + } - _dbContext.Assignments.Add(assignment); await _dbContext.SaveChangesAsync(); } } @@ -291,9 +299,6 @@ With that in place, let's add the experimentation service to Index.cshtml.cs and public class CatalogDisplayOptions { public int ItemsPerPage { get; set; } = Constants.ITEMS_PER_PAGE; - - // Add this field - it will be populated by Excos - public FeatureMetadata? FeatureMetadata { get; set; } } // Index.cshtml.cs @@ -302,14 +307,8 @@ public async Task OnGet(CatalogIndexViewModel catalogModel, int? pageId) var context = HttpContext.ExtractStoreOptionsContext(); var options = await _contextualOptions.GetAsync(context, default); - // Add this call to experimentation service based on metadata - if (options.FeatureMetadata is not null) - { - foreach (var feature in options.FeatureMetadata.Features) - { - await _experimentationService.SaveExperimentAssignmentAsync(context.SessionId, feature.FeatureName, feature.VariantId); - } - } + // Add this call to experimentation service + _experimentationService.SaveExperimentAssignmentAsync(context); CatalogModel = await _catalogViewModelService.GetCatalogItems(pageId ?? 0, options.ItemsPerPage, catalogModel.BrandFilterApplied, catalogModel.TypesFilterApplied); } @@ -348,18 +347,13 @@ Let's start by connecting GrowthBook to our SQL Server instance. Go to "Metrics ![Add data source](./images/growthbook-demo-create-data-source.png) -With that we will redefine the Identifier Type as `sessionid` and create the experiment assignment query called `ExperimentAssignmentBySession`. In the SQL below I'm doing a bit of a manipulation with the ExperimentName and VariantId. This is due to slight misalignment between Excos data schema and GrowthBooks. A GrowthBook experiment is saved as a VariantId `{tracking-label}:{treatmentId}`, while ExperimentName would contain the Feature name. Meanwhile, a rollout or static value override defined in GrowthBook would be only identified by Feature name and variant id. This query will allow you to use both GrowthBook experiments and native Excos experiments. +With that we will redefine the Identifier Type as `sessionid` and create the experiment assignment query called `ExperimentAssignmentBySession`. In the SQL below I'm doing a bit of a manipulation to extract experiment label and variant id for GrowthBook. This is due to the fact that Excos expects variant Ids to be uniquely identifying both the feature and variant. A GrowthBook experiment is saved as a VariantId `{tracking-label}:{treatmentId}`. This query will allow you to use both GrowthBook experiments and other Excos experiments using the configuration provider. ```sql SELECT SessionId as sessionid, Timestamp as timestamp, - COALESCE( - NULLIF( - SUBSTRING(VariantId, 1, - (GREATEST(1, CHARINDEX(':', VariantId)) - 1)), - ''), - ExperimentName) as experiment_id, + SUBSTRING(VariantId, 1, (GREATEST(1, CHARINDEX(':', VariantId)) - 1)) as experiment_id, SUBSTRING(VariantId, GREATEST(1, CHARINDEX(':', VariantId) + 1), 128) as variation_id FROM [exp].[Assignment] ``` @@ -422,8 +416,10 @@ I've created a simple A/A test (meaning no difference between control and treatm ```csharp builder.Services.BuildFeature("OfflineExperiment") - .Configure(feature => feature.AllocationUnit = nameof(StoreOptionsContext.SessionId)) - .ABExperiment((_, _) => { }, (_, _) => { }) // no change A/A experiment + .ABExperiment( + (_, _) => { }, + (_, _) => { }, // no change A/A experiment + allocationUnit: nameof(StoreOptionsContext.SessionId)) .Save(); ``` diff --git a/docs/Usage.md b/docs/Usage.md index c3425a2..1e8b8f3 100644 --- a/docs/Usage.md +++ b/docs/Usage.md @@ -5,7 +5,7 @@ Following the Options.Contextual docs: 1\. Define your context class that will be passed to `IContextualOptions.GetAsync()`. -By default `UserId` property is used for allocation calculations. You can override it globally by configuring `ExcosOptions` or override it for a single feature by configuring the `AllocationUnit` property. +By default `UserId` property is used for allocation calculations. You can override it for a single feature by configuring the `AllocationUnit` property. ```csharp [OptionsContext] @@ -103,43 +103,6 @@ internal class WeatherForecastService } ``` -## Variant overrides -You can also allow overriding the variant based on some context - example: - -```csharp -class TestUserOverride : IFeatureVariantOverride -{ - public Task TryOverrideAsync(Feature experiment, TContext optionsContext, CancellationToken cancellationToken) - where TContext : IOptionsContext - { - var receiver = new Receiver(); - optionsContext.PopulateReceiver(receiver); - if (experiment.Name == "MyExp" && receiver.UserId.IsTestUser()) - { - return new VariantOverride - { - Id = "MyVariant", - OverrideProviderName = nameof(TestUserOverride), - }; - } - - return null; - } - - private class Receiver : IOptionsContextReceiver - { - public Guid UserId; - public void Receive(string key, T value) - { - if (key == nameof(UserId)) - { - UserId = (Guid)value; - } - } - } -} -``` - ## Usages * Experiments diff --git a/src/Excos.Benchmarks/ExcosVsFeatureManagement.cs b/src/Excos.Benchmarks/ExcosVsFeatureManagement.cs index 2ae8edf..a66f91d 100644 --- a/src/Excos.Benchmarks/ExcosVsFeatureManagement.cs +++ b/src/Excos.Benchmarks/ExcosVsFeatureManagement.cs @@ -3,11 +3,9 @@ using System.Text; using BenchmarkDotNet.Attributes; -using Excos.Options.Abstractions; -using Excos.Options.Abstractions.Data; +using Excos.Options; using Excos.Options.Contextual; using Excos.Options.Providers; -using Excos.Options.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options.Contextual; @@ -31,21 +29,12 @@ private IServiceProvider BuildExcosProvider() var services = new ServiceCollection(); services.ConfigureExcos("Test"); services.AddExcosOptionsFeatureProvider(); - services.AddOptions() - .Configure(features => features.Add(new Feature - { - Name = "TestFeature", - ProviderName = "Tests", - Variants = + services.BuildFeature("TestFeature", "Tests") + .Rollout(100, (options, name) => { - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new BasicConfigureOptions(), - Id = "Basic" - } - } - })); + options.Setting = "Test"; + }) + .Save(); return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = false, ValidateOnBuild = false }); } @@ -96,20 +85,18 @@ public object BuildAndResolveFM() } [Benchmark] - public async Task GetExcosSettingsPooled() + public async Task GetExcosSettingsContextual() { - PrivateObjectPool.EnablePooling = true; var contextualOptions = _excosProvider.GetRequiredService>(); var options = await contextualOptions.GetAsync(new TestContext(), default); return options.Setting; } [Benchmark] - public async Task GetExcosSettingsNew() + public async Task GetExcosSettingsFeatureEvaluation() { - PrivateObjectPool.EnablePooling = false; - var contextualOptions = _excosProvider.GetRequiredService>(); - var options = await contextualOptions.GetAsync(new TestContext(), default); + var eval = _excosProvider.GetRequiredService(); + var options = await eval.EvaluateFeaturesAsync(string.Empty, new TestContext(), default); return options.Setting; } @@ -128,17 +115,6 @@ private class TestOptions { public string Setting { get; set; } = string.Empty; } - - private class BasicConfigureOptions : IConfigureOptions - { - public void Configure(TOptions input, string section) where TOptions : class - { - if (input is TestOptions test) - { - test.Setting = "Test"; - } - } - } } [OptionsContext] diff --git a/src/Excos.Benchmarks/Readme.md b/src/Excos.Benchmarks/Readme.md index 13e4620..c4d17d8 100644 --- a/src/Excos.Benchmarks/Readme.md +++ b/src/Excos.Benchmarks/Readme.md @@ -1,23 +1,29 @@ # Benchmark I wanted to see how does Excos compare performance wise to [Microsoft.FeatureManagement](). -It is about 2x slower at the moment. However, I need to point out that it operates differently. With Excos and Contextual Options you're actually applying configuration over an Options class which can contain many different settings, while with FM you're checking a single feature flag to be True or False. +It seems that Options.Contextual version is only a little bit slower than FM, while the pure Excos feature evaluation may be faster. -I was able to optimize the amount of allocations with object pools. The biggest memory and perf hog was using LINQ. -Given how most of the time you will be making fewer calls to Excos than to FM it might be enough at this point. +However, I want to highlight here that Excos evaluation speed will depend on how the configuration binding and filtering is executed. This benchmark covers only a very small piece of the puzzle and real world performance characteristics may differ. ``` -BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2715/22H2/2022Update/SunValley2) +BenchmarkDotNet v0.13.11, Windows 11 (10.0.22631.4602/23H2/2023Update/SunValley3) 12th Gen Intel Core i5-1235U, 1 CPU, 12 logical and 10 physical cores -.NET SDK 8.0.100 - [Host] : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2 - DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2 +.NET SDK 8.0.401 + [Host] : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2 + DefaultJob : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX2 ``` -| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | -|----------------------- |------------:|----------:|------------:|-------:|-------:|----------:| -| BuildAndResolveExcos | 21,844.2 ns | 427.07 ns | 1,063.56 ns | 4.3945 | 0.9766 | 27954 B | -| BuildAndResolveFM | 7,297.5 ns | 160.23 ns | 464.84 ns | 2.8381 | 0.7019 | 17921 B | -| GetExcosSettingsPooled | 996.2 ns | 19.13 ns | 16.96 ns | 0.0591 | - | 376 B | -| GetExcosSettingsNew | 872.9 ns | 17.20 ns | 16.89 ns | 0.1345 | - | 848 B | -| GetFMSetting | 387.3 ns | 7.05 ns | 8.92 ns | 0.1578 | - | 992 B | +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|---------------------------------- |------------:|----------:|----------:|-------:|-------:|----------:| +| BuildAndResolveExcos | 14,003.7 ns | 129.04 ns | 114.39 ns | 3.6011 | 0.8545 | 22784 B | +| BuildAndResolveFM | 5,979.1 ns | 92.25 ns | 86.29 ns | 2.8381 | 0.7019 | 17841 B | +| GetExcosSettingsContextual | 439.9 ns | 8.43 ns | 10.35 ns | 0.1249 | - | 784 B | +| GetExcosSettingsFeatureEvaluation | 287.3 ns | 3.59 ns | 3.36 ns | 0.0930 | - | 584 B | +| GetFMSetting | 402.6 ns | 5.01 ns | 4.19 ns | 0.1578 | - | 992 B | + +## Previous notes (before beta) + +It is about 2x slower at the moment. However, I need to point out that it operates differently. With Excos and Contextual Options you're actually applying configuration over an Options class which can contain many different settings, while with FM you're checking a single feature flag to be True or False. + +I was able to optimize the amount of allocations with object pools. The biggest memory and perf hog was using LINQ. +Given how most of the time you will be making fewer calls to Excos than to FM it might be enough at this point. \ No newline at end of file diff --git a/src/Excos.Options.GrowthBook.Tests/Cases.cs b/src/Excos.Options.GrowthBook.Tests/Cases.cs index f328b03..b845e56 100644 --- a/src/Excos.Options.GrowthBook.Tests/Cases.cs +++ b/src/Excos.Options.GrowthBook.Tests/Cases.cs @@ -9,7 +9,7 @@ public static class Cases { public static IEnumerable EvalConditions => JsonSerializer.Deserialize(CasesJson)!.RootElement.GetProperty("evalCondition").EnumerateArray().Select(x => new object[] { x[0].GetString()!, x[1], x[2], x[3].GetBoolean() }); public static IEnumerable Hash => JsonSerializer.Deserialize(CasesJson)!.RootElement.GetProperty("hash").EnumerateArray().Select(x => new object[] { x[0].GetString()!, x[1].GetString()!, x[2].GetInt32(), x[3].ValueKind == JsonValueKind.Null ? null! : (double?)x[3].GetDouble() }); - public static IEnumerable VersionCompareEQ => JsonSerializer.Deserialize(CasesJson)!.RootElement.GetProperty("versionCompare").GetProperty("eq").EnumerateArray().Select(x => new object[] { x[0].GetString()!, x[1].GetString()!, x[2].GetBoolean() }); + public static IEnumerable VersionCompare => JsonSerializer.Deserialize(CasesJson)!.RootElement.GetProperty("versionCompare").EnumerateObject().SelectMany(p => p.Value.EnumerateArray().Select(x => new object[] { p.Name, x[0].GetString()!, x[1].GetString()!, x[2].GetBoolean() })); /// /// https://github.com/growthbook/growthbook/blob/main/packages/sdk-js/test/cases.json @@ -635,20 +635,6 @@ public static class Cases }, false ], - [ - "missing attribute with comparison operators", - { - "age": { - "$gt": -10, - "$lt": 10, - "$gte": -9, - "$lte": 9, - "$ne": 10 - } - }, - {}, - true - ], [ "comparing numbers and strings", { @@ -918,19 +904,6 @@ public static class Cases }, true ], - [ - "$gt/$lt strings - fail uppercase", - { - "word": { - "$gt": "alphabet", - "$lt": "zebra" - } - }, - { - "word": "AZL" - }, - false - ], [ "nested value is null", { @@ -1604,21 +1577,6 @@ public static class Cases }, true ], - [ - "equals object - fail extra property", - { - "tags": { - "hello": "world" - } - }, - { - "tags": { - "hello": "world", - "yes": "please" - } - }, - false - ], [ "equals object - fail missing property", { @@ -2081,7 +2039,6 @@ public static class Cases ["1.2.3", "1.2.3-4-foo", true], ["1.2.3-5-foo", "1.2.3-5", true], ["1.2.3-5", "1.2.3-4", true], - ["1.2.3-5-foo", "1.2.3-5-Foo", true], ["3.0.0", "2.7.2+asdf", true], ["1.2.3-a.10", "1.2.3-a.5", true], ["1.2.3-a.b", "1.2.3-a.5", true], @@ -2089,7 +2046,6 @@ public static class Cases ["1.2.3-a.b.c", "1.2.3-a.b.c.d", false], ["1.2.3-a.b.c.10.d.5", "1.2.3-a.b.c.5.d.100", true], ["1.2.3-r2", "1.2.3-r100", true], - ["1.2.3-r100", "1.2.3-R2", true], ["a.b.c.d.e.f", "1.2.3", true], ["10.0.0", "9.0.0", true], ["10000.0.0", "9999.0.0", true] diff --git a/src/Excos.Options.GrowthBook.Tests/Tests.cs b/src/Excos.Options.GrowthBook.Tests/Tests.cs index 0b9cdbf..f9c894a 100644 --- a/src/Excos.Options.GrowthBook.Tests/Tests.cs +++ b/src/Excos.Options.GrowthBook.Tests/Tests.cs @@ -1,6 +1,7 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 +using System.Text.Json; using Excos.Options.Abstractions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -177,15 +178,10 @@ public async Task FeaturesAreParsed() Assert.Equal(3, features.Count); Assert.Equal("newlabel", features[0].Name); - Assert.Equal(2, features[0].Variants.Count); - Assert.Equal("label:0", features[0].Variants[0].Id); - Assert.Equal("label:1", features[0].Variants[1].Id); - Assert.Equal(2, features[0].Variants[0].Filters.Count); - Assert.True(features[0].Variants[0].Filters.Contains("country")); - Assert.Equal("country", features[0].Variants[0].Filters["country"].PropertyName); - var inFilter = Assert.IsType(Assert.Single(features[0].Variants[0].Filters["country"].Conditions)); - Assert.True(inFilter.IsSatisfiedBy("US")); - Assert.True(inFilter.IsSatisfiedBy("UK")); + Assert.Equal(2, features[0].Count); + Assert.Equal("label:0", features[0][0].Id); + Assert.Equal("label:1", features[0][1].Id); + Assert.Equal(3, features[0][0].Filters.Count()); // attribute filter + allocation + namespace Assert.Equal("gbdemo-checkout-layout", features[1].Name); @@ -203,13 +199,27 @@ public async Task ConfigurationIsSetUp() Assert.Equal("current", config.GetValue("gbdemo-checkout-layout")); } - // These tests seem to be using a different format to what the API returns... - //[Theory] - //[MemberData(nameof(Cases.EvalConditions), MemberType = typeof(Cases))] - //public void EvalConditions_Test(string name, JsonElement condition, JsonElement attributes, bool expected) - //{ - // var filter = FilterParser.ParseFilters(condition); - //} + // Exceptions: + // (name: "equals object - fail extra property", + // condition: { "tags": { "hello": "world" } }, + // attributes: { "tags": { "hello": "world", "yes": "please" } }, + // expected: False) - we will only enforce properties specified, you can add extra properties without failing existing filters + // (name: "$gt/$lt strings - fail uppercase", + // condition: { "word": { "$gt": "alphabet", "$lt": "zebra" } }, + // attributes: { "word": "AZL" }, + // expected: False) - we do case insensitive string comparison + // (name: "missing attribute with comparison operators", + // condition: { "age": { "$gt": -10, "$lt": 10, "$gte": -9, "$lte": 9, "$ne": 10 } }, + // attributes: {}, + // expected: True) - I don't know why this was supposed to pass + [Theory] + [MemberData(nameof(Cases.EvalConditions), MemberType = typeof(Cases))] + public void EvalConditions_Test(string name, JsonElement condition, JsonElement attributes, bool expected) + { + _ = name; // get compiler off my back + var filter = FilterParser.ParseCondition(condition); + Assert.Equal(expected, filter.IsSatisfied(attributes)); + } [Theory] [MemberData(nameof(Cases.Hash), MemberType = typeof(Cases))] @@ -220,13 +230,25 @@ public void Hash_Test(string seed, string identifier, int version, double? resul Assert.Equal(result, hash == -1 ? null : hash); } + // Exceptions: + // ["gt", "1.2.3-5-foo", "1.2.3-5-Foo", true] - because I do case insensitive compare + // ["gt, "1.2.3-r100", "1.2.3-R2", true], - because I do case insensitive compare [Theory] - [MemberData(nameof(Cases.VersionCompareEQ), MemberType = typeof(Cases))] - public void VersionCompareEQ_Test(string left, string right, bool match) + [MemberData(nameof(Cases.VersionCompare), MemberType = typeof(Cases))] + public void VersionCompare_Test(string op, string left, string right, bool match) { - Assert.True(ComparisonVersionStringFilter.TryParse(left, out var version)); - var algorithm = new ComparisonVersionStringFilter(i => i == 0, version); - Assert.Equal(match, algorithm.IsSatisfiedBy(right)); + var comparisonType = op switch + { + "eq" => ComparisonType.Equal, + "ne" => ComparisonType.NotEqual, + "lt" => ComparisonType.LessThan, + "lte" => ComparisonType.LessThanOrEqual, + "gt" => ComparisonType.GreaterThan, + "gte" => ComparisonType.GreaterThanOrEqual, + _ => throw new Exception(op) + }; + var algorithm = new ComparisonVersionStringFilter(comparisonType, right); + Assert.Equal(match, algorithm.IsSatisfied(JsonSerializer.SerializeToElement(left))); } private IHost BuildHost(GrowthBookOptions options) diff --git a/src/Excos.Options.GrowthBook/Excos.Options.GrowthBook.csproj b/src/Excos.Options.GrowthBook/Excos.Options.GrowthBook.csproj index b1179cb..1e83302 100644 --- a/src/Excos.Options.GrowthBook/Excos.Options.GrowthBook.csproj +++ b/src/Excos.Options.GrowthBook/Excos.Options.GrowthBook.csproj @@ -6,15 +6,18 @@ enable true true + + + EXTEXP0017 Excos.Options.Growthbook - 1.0.0-alpha1 + 1.0.0-beta1 Marian Dziubiak and Contributors GrowthBook integration with Excos.Options Apache-2.0 - https://github.com/manio143/excos + https://github.com/excos-platform/config-client README.md diff --git a/src/Excos.Options.GrowthBook/Filtering.cs b/src/Excos.Options.GrowthBook/Filtering.cs index 8f9cba3..9574728 100644 --- a/src/Excos.Options.GrowthBook/Filtering.cs +++ b/src/Excos.Options.GrowthBook/Filtering.cs @@ -1,36 +1,44 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 -using System.Collections; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Excos.Options.Abstractions; using Excos.Options.Abstractions.Data; using Excos.Options.Filtering; +using Microsoft.Extensions.Options.Contextual; namespace Excos.Options.GrowthBook; -internal static class FilterParser +internal interface IFilter +{ + public bool IsSatisfied(JsonElement context); +} + +internal class JsonFilteringCondition : IFilteringCondition { - public static Dictionary ParseFilters(JsonElement conditions) + private readonly IFilter _filter; + public JsonFilteringCondition(IFilter filter) { - var filters = new Dictionary(); - if (conditions.ValueKind == JsonValueKind.Object) - { - foreach (var condition in conditions.EnumerateObject()) - { - var key = condition.Name; - var value = condition.Value; - filters.Add(key, ParseCondition(value)); - } - } + _filter = filter; + } + public bool IsSatisfiedBy(T value) where T : IOptionsContext + { + return _filter.IsSatisfied(JsonSerializer.SerializeToElement(value)); + } +} - return filters; +internal static class FilterParser +{ + public static IFilteringCondition ParseFilters(JsonElement conditions) + { + var filter = ParseCondition(conditions); + return new JsonFilteringCondition(filter); } - private static IFilteringCondition ParseCondition(JsonElement condition) + internal static IFilter ParseCondition(JsonElement condition) { if (condition.ValueKind == JsonValueKind.Object) { @@ -44,213 +52,177 @@ private static IFilteringCondition ParseCondition(JsonElement condition) return new AndFilter(properties.Select(ParseConditionOperator)); } } - else if (condition.ValueKind == JsonValueKind.Number) - { - return new ComparisonNumberFilter(r => r == 0, condition.GetDouble()); - } - else if (condition.ValueKind == JsonValueKind.String) - { - return new ComparisonStringFilter(r => r == 0, condition.GetString()!); - } else if (condition.ValueKind == JsonValueKind.Array) { - var values = condition.EnumerateArray().Select(v => v.GetString()!).ToList(); - return new InFilter(values); + var values = condition.EnumerateArray().Select(ParseCondition).ToList(); + return new ArrayFilter(values); } - else if (condition.ValueKind == JsonValueKind.True) - { - return new ComparisonBoolFilter(r => r == 0, true); - } - else if (condition.ValueKind == JsonValueKind.False) + else { - return new ComparisonBoolFilter(r => r == 0, false); + return new ComparisonFilter(ComparisonType.Equal, condition); } - - return NeverFilteringCondition.Instance; } - private static IFilteringCondition ParseConditionOperator(JsonProperty property) + private static IFilter ParseConditionOperator(JsonProperty property) { - Version? version; - if (property.Name == "$exists") - { - if (property.Value.ValueKind == JsonValueKind.False) - { - return new NotFilter(new ExistsFilter()); - } - else if (property.Value.ValueKind == JsonValueKind.True) - { - return new ExistsFilter(); - } - } - else if (property.Name == "$not") - { - return new NotFilter(ParseCondition(property.Value)); - } - else if (property.Name == "$and") - { - return new AndFilter(property.Value.EnumerateArray().Select(ParseCondition)); - } - else if (property.Name == "$or") - { - return new OrFilter(property.Value.EnumerateArray().Select(ParseCondition)); - } - else if (property.Name == "$nor") - { - return new NotFilter(new OrFilter(property.Value.EnumerateArray().Select(ParseCondition))); - } - else if (property.Name == "$in") - { - return new InFilter(property.Value.EnumerateArray().Select(v => v.GetString()!)); - } - else if (property.Name == "$nin") - { - return new NotFilter(new InFilter(property.Value.EnumerateArray().Select(v => v.GetString()!))); - } - else if (property.Name == "$all") - { - return new AllFilter(property.Value.EnumerateArray().Select(ParseCondition)); - } - else if (property.Name == "$elemMatch") - { - return new ElemMatchFilter(ParseCondition(property.Value)); - } - else if (property.Name == "$size") - { - return new SizeFilter(property.Value.GetInt32()); - } - else if (property.Name == "$gt") - { - if (property.Value.ValueKind == JsonValueKind.Number) - return new ComparisonNumberFilter(r => r > 0, property.Value.GetDouble()); - else if (property.Value.ValueKind == JsonValueKind.String) - return new ComparisonStringFilter(r => r > 0, property.Value.GetString()!); - } - else if (property.Name == "$gte") - { - if (property.Value.ValueKind == JsonValueKind.Number) - return new ComparisonNumberFilter(r => r >= 0, property.Value.GetDouble()); - else if (property.Value.ValueKind == JsonValueKind.String) - return new ComparisonStringFilter(r => r >= 0, property.Value.GetString()!); - } - else if (property.Name == "$lt") - { - if (property.Value.ValueKind == JsonValueKind.Number) - return new ComparisonNumberFilter(r => r < 0, property.Value.GetDouble()); - else if (property.Value.ValueKind == JsonValueKind.String) - return new ComparisonStringFilter(r => r < 0, property.Value.GetString()!); - } - else if (property.Name == "$lte") - { - if (property.Value.ValueKind == JsonValueKind.Number) - return new ComparisonNumberFilter(r => r <= 0, property.Value.GetDouble()); - else if (property.Value.ValueKind == JsonValueKind.String) - return new ComparisonStringFilter(r => r <= 0, property.Value.GetString()!); - } - else if (property.Name == "$eq") - { - if (property.Value.ValueKind == JsonValueKind.Number) - return new ComparisonNumberFilter(r => r == 0, property.Value.GetDouble()); - else if (property.Value.ValueKind == JsonValueKind.String) - return new ComparisonStringFilter(r => r == 0, property.Value.GetString()!); - } - else if (property.Name == "$ne") + switch (property.Name) { - if (property.Value.ValueKind == JsonValueKind.Number) - return new ComparisonNumberFilter(r => r != 0, property.Value.GetDouble()); - else if (property.Value.ValueKind == JsonValueKind.String) - return new ComparisonStringFilter(r => r != 0, property.Value.GetString()!); - } - else if (property.Name == "$regex") - { - return new RegexFilteringCondition(property.Value.GetString()!); - } - else if (property.Name == "$vgt" && ComparisonVersionStringFilter.TryParse(property.Value.GetString()!, out version)) - { - return new ComparisonVersionStringFilter(r => r > 0, version); - } - else if (property.Name == "$vgte" && ComparisonVersionStringFilter.TryParse(property.Value.GetString()!, out version)) - { - return new ComparisonVersionStringFilter(r => r >= 0, version); - } - else if (property.Name == "$vlt" && ComparisonVersionStringFilter.TryParse(property.Value.GetString()!, out version)) - { - return new ComparisonVersionStringFilter(r => r < 0, version); ; - } - else if (property.Name == "$vlte" && ComparisonVersionStringFilter.TryParse(property.Value.GetString()!, out version)) - { - return new ComparisonVersionStringFilter(r => r <= 0, version); - } - else if (property.Name == "$veq" && ComparisonVersionStringFilter.TryParse(property.Value.GetString()!, out version)) - { - return new ComparisonVersionStringFilter(r => r == 0, version); - } - else if (property.Name == "$vne" && ComparisonVersionStringFilter.TryParse(property.Value.GetString()!, out version)) - { - return new ComparisonVersionStringFilter(r => r != 0, version); + case "$exists": + if (property.Value.ValueKind == JsonValueKind.False) + { + return new NotFilter(new ExistsFilter()); + } + else + { + return new ExistsFilter(); + } + case "$not": + return new NotFilter(ParseCondition(property.Value)); + case "$and": + return new AndFilter(property.Value.EnumerateArray().Select(ParseCondition)); + case "$or": + return new OrFilter(property.Value.EnumerateArray().Select(ParseCondition)); + case "$nor": + return new NotFilter(new OrFilter(property.Value.EnumerateArray().Select(ParseCondition))); + case "$in": + if (property.Value.ValueKind != JsonValueKind.Array) + { + return NeverFilter.Instance; + } + return new InFilter(property.Value.EnumerateArray()); + case "$nin": + if (property.Value.ValueKind != JsonValueKind.Array) + { + return NeverFilter.Instance; + } + return new NotFilter(new InFilter(property.Value.EnumerateArray())); + case "$all": + return new AllFilter(property.Value.EnumerateArray().Select(ParseCondition)); + case "$elemMatch": + return new ElemMatchFilter(ParseCondition(property.Value)); + case "$size": + return new SizeFilter(ParseCondition(property.Value)); + case "$gt": + return new ComparisonFilter(ComparisonType.GreaterThan, property.Value); + case "$gte": + return new ComparisonFilter(ComparisonType.GreaterThanOrEqual, property.Value); + case "$lt": + return new ComparisonFilter(ComparisonType.LessThan, property.Value); + case "$lte": + return new ComparisonFilter(ComparisonType.LessThanOrEqual, property.Value); + case "$eq": + return new ComparisonFilter(ComparisonType.Equal, property.Value); + case "$ne": + return new ComparisonFilter(ComparisonType.NotEqual, property.Value); + case "$regex": + return new RegexFilter(property.Value.GetString()!); + case "$vgt": + return new ComparisonVersionStringFilter(ComparisonType.GreaterThan, property.Value.GetString() ?? string.Empty); + case "$vgte": + return new ComparisonVersionStringFilter(ComparisonType.GreaterThanOrEqual, property.Value.GetString() ?? string.Empty); + case "$vlt": + return new ComparisonVersionStringFilter(ComparisonType.LessThan, property.Value.GetString() ?? string.Empty); + case "$vlte": + return new ComparisonVersionStringFilter(ComparisonType.LessThanOrEqual, property.Value.GetString() ?? string.Empty); + case "$veq": + return new ComparisonVersionStringFilter(ComparisonType.Equal, property.Value.GetString() ?? string.Empty); + case "$vne": + return new ComparisonVersionStringFilter(ComparisonType.NotEqual, property.Value.GetString() ?? string.Empty); + case "$type": + return new TypeFilter(property.Value.GetString()!); + default: + return new PropertyFilter(property.Name, ParseCondition(property.Value)); } - - return NeverFilteringCondition.Instance; } } -internal class NamespaceInclusiveFilter : IFilteringCondition +internal class NamespaceFilteringCondition : PropertyFilteringCondition { private readonly string _namespaceId; private readonly Range _range; - private readonly IFilteringCondition? _innerCondition; - public NamespaceInclusiveFilter(string namespaceId, Range range, IFilteringCondition? innerCondition) + public NamespaceFilteringCondition(string allocationUnit, string namespaceId, Range range) + : base(allocationUnit) { _namespaceId = namespaceId; _range = range; - _innerCondition = innerCondition; } - public bool IsSatisfiedBy(T value) + protected override bool PropertyPredicate(T value) { var n = GrowthBookHash.V1.GetAllocationSpot($"__{_namespaceId}", value?.ToString() ?? string.Empty); - return _range.Contains(n) && (_innerCondition?.IsSatisfiedBy(value) ?? true); + return _range.Contains(n); } } -internal class OrFilter : IFilteringCondition +internal class AllocationFilteringCondition : PropertyFilteringCondition { - private readonly IEnumerable _conditions; + private readonly string _salt; + private readonly GrowthBookHash _hash; + private readonly Allocation _range; - public OrFilter(IEnumerable conditions) + public AllocationFilteringCondition(string allocationUnit, string salt, GrowthBookHash hash, Allocation range) + : base(allocationUnit) + { + _salt = salt; + _hash = hash; + _range = range; + } + + protected override bool PropertyPredicate(T value) + { + var n = _hash.GetAllocationSpot(value?.ToString() ?? string.Empty, _salt); + return _range.Contains(n); + } +} + +internal class NeverFilter : IFilter +{ + public static NeverFilter Instance = new(); + public bool IsSatisfied(JsonElement context) + { + return false; + } +} + +internal class OrFilter : IFilter +{ + private readonly IEnumerable _conditions; + + public OrFilter(IEnumerable conditions) { _conditions = conditions; } - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { foreach (var condition in _conditions) { - if (condition.IsSatisfiedBy(value)) + if (condition.IsSatisfied(context)) { return true; } } - return false; + // return true if empty, false otherwise + return !_conditions.Any(); } } -internal class AndFilter : IFilteringCondition +internal class AndFilter : IFilter { - private readonly IEnumerable _conditions; + private readonly IEnumerable _conditions; - public AndFilter(IEnumerable conditions) + public AndFilter(IEnumerable conditions) { _conditions = conditions; } - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { foreach (var condition in _conditions) { - if (!condition.IsSatisfiedBy(value)) + if (!condition.IsSatisfied(context)) { return false; } @@ -260,45 +232,45 @@ public bool IsSatisfiedBy(T value) } } -internal class NotFilter : IFilteringCondition +internal class NotFilter : IFilter { - private readonly IFilteringCondition _condition; + private readonly IFilter _condition; - public NotFilter(IFilteringCondition condition) + public NotFilter(IFilter condition) { _condition = condition; } - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { - return !_condition.IsSatisfiedBy(value); + return !_condition.IsSatisfied(context); } } -internal class ExistsFilter : IFilteringCondition +internal class ExistsFilter : IFilter { - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { - return value != null; + return context.ValueKind != JsonValueKind.Undefined; } } -internal class InFilter : IFilteringCondition +internal class InFilter : IFilter { - private readonly IEnumerable _values; + private readonly IEnumerable _values; - public InFilter(IEnumerable values) + public InFilter(IEnumerable values) { _values = values; } - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { - if (value is IEnumerable strings) + if (context.ValueKind == JsonValueKind.Array) { - foreach (var elem in strings) + foreach (var elem in context.EnumerateArray()) { - if (_values.Contains(elem)) + if (_values.Contains(elem, Comparison.ElementComparer)) { return true; } @@ -308,154 +280,217 @@ public bool IsSatisfiedBy(T value) } else { - return _values.Contains(value?.ToString() ?? string.Empty); + return _values.Contains(context, Comparison.ElementComparer); } } } -internal class ComparisonBoolFilter : IFilteringCondition +internal enum ComparisonType { - private readonly Func _comparisonResult; - private readonly bool _value; - - public ComparisonBoolFilter(Func comparisonResult, bool value) - { - _comparisonResult = comparisonResult; - _value = value; - } - - public bool IsSatisfiedBy(T value) - { - if (typeof(T) == typeof(bool)) - { - return _comparisonResult(Unsafe.As(ref value).CompareTo(_value)); - } - - return false; - } + Equal, + NotEqual, + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual } -internal class ComparisonStringFilter : IFilteringCondition +internal class ComparisonFilter : IFilter { - private readonly Func _comparisonResult; - private readonly string _value; + private readonly ComparisonType _comparisonType; + private readonly JsonElement _value; - public ComparisonStringFilter(Func comparisonResult, string value) + public ComparisonFilter(ComparisonType comparisonType, JsonElement value) { - _comparisonResult = comparisonResult; + _comparisonType = comparisonType; _value = value; } - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { - return _comparisonResult(string.Compare(value?.ToString(), _value, StringComparison.InvariantCultureIgnoreCase)); + return Comparison.Compare(context, _value, _comparisonType); } } -internal class ComparisonVersionStringFilter : IFilteringCondition +internal static class Comparison { - private readonly Func _comparisonResult; - private readonly Version _value; - - public ComparisonVersionStringFilter(Func comparisonResult, Version value) - { - _comparisonResult = comparisonResult; - _value = value; - } - - public bool IsSatisfiedBy(T value) + // we will do case insensitive equality comparison for string values + public static bool Compare(string left, string right, ComparisonType comparisonType) { - if (TryParse(value, out var version)) - { - return _comparisonResult(version.CompareTo(_value)); + switch (comparisonType) + { + case ComparisonType.Equal: + return left.Equals(right, StringComparison.OrdinalIgnoreCase); + case ComparisonType.NotEqual: + return !left.Equals(right, StringComparison.OrdinalIgnoreCase); + case ComparisonType.GreaterThan: + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase) > 0; + case ComparisonType.GreaterThanOrEqual: + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase) >= 0; + case ComparisonType.LessThan: + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase) < 0; + case ComparisonType.LessThanOrEqual: + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase) <= 0; + default: + return false; } - - return false; } - - public static bool TryParse(T input, [NotNullWhen(true)] out Version? version) => Version.TryParse(Regex.Replace(input?.ToString() ?? string.Empty, "(^v|\\+.*$)", "").Replace('-', '.'), out version); -} - -internal class ComparisonNumberFilter : IFilteringCondition -{ - private readonly Func _comparisonResult; - private readonly double _value; - - public ComparisonNumberFilter(Func comparisonResult, double value) + public static bool Compare(T left, T right, ComparisonType comparisonType) where T : IComparable { - _comparisonResult = comparisonResult; - _value = value; + switch (comparisonType) + { + case ComparisonType.Equal: + return left.CompareTo(right) == 0; + case ComparisonType.NotEqual: + return left.CompareTo(right) != 0; + case ComparisonType.GreaterThan: + return left.CompareTo(right) > 0; + case ComparisonType.GreaterThanOrEqual: + return left.CompareTo(right) >= 0; + case ComparisonType.LessThan: + return left.CompareTo(right) < 0; + case ComparisonType.LessThanOrEqual: + return left.CompareTo(right) <= 0; + default: + return false; + } } - public bool IsSatisfiedBy(T value) + public static bool Compare(JsonElement left, JsonElement right, ComparisonType comparisonType) { - if (value is double d) + if (left.ValueKind == JsonValueKind.String && right.ValueKind == JsonValueKind.Number + && double.TryParse(left.GetString(), out var number)) { - return _comparisonResult(d.CompareTo(_value)); + return Compare(number, right.GetDouble(), comparisonType); } - else if (value is float f) + else if (left.ValueKind == JsonValueKind.Number && right.ValueKind == JsonValueKind.String + && double.TryParse(right.GetString(), out number)) { - return _comparisonResult(f.CompareTo(_value)); + return Compare(left.GetDouble(), number, comparisonType); } - else if (value is int i) + else if ((left.ValueKind == JsonValueKind.Undefined && right.ValueKind == JsonValueKind.Null) + || (left.ValueKind == JsonValueKind.Null && right.ValueKind == JsonValueKind.Undefined) + && comparisonType == ComparisonType.Equal) { - return _comparisonResult(i.CompareTo(_value)); + // special case null = undefined + return true; } - else if (value is uint u) + else if (left.ValueKind != right.ValueKind) { - return _comparisonResult(u.CompareTo(_value)); - } - else if (value is short s) - { - return _comparisonResult(s.CompareTo(_value)); + return false; } - else if (value is ushort us) + + switch (left.ValueKind) { - return _comparisonResult(us.CompareTo(_value)); + case JsonValueKind.Number: + return Compare(left.GetDouble(), right.GetDouble(), comparisonType); + case JsonValueKind.String: + return Compare(left.GetString()!, right.GetString()!, comparisonType); + case JsonValueKind.True: + case JsonValueKind.False: + return Compare(left.GetBoolean(), right.GetBoolean(), comparisonType); + case JsonValueKind.Null: + return comparisonType == ComparisonType.Equal; + case JsonValueKind.Undefined: + default: + return false; } + } - return false; + public static IEqualityComparer ElementComparer = new JsonElementComparer(); + private class JsonElementComparer : IEqualityComparer + { + public bool Equals(JsonElement x, JsonElement y) + => Compare(x, y, ComparisonType.Equal); + + public int GetHashCode([DisallowNull] JsonElement obj) + => obj.GetRawText().GetHashCode(); } } -internal class ElemMatchFilter : IFilteringCondition +internal partial class ComparisonVersionStringFilter : IFilter { - private readonly IFilteringCondition _condition; + private readonly ComparisonType _comparisonType; + private readonly string _value; - public ElemMatchFilter(IFilteringCondition condition) + public ComparisonVersionStringFilter(ComparisonType comparisonType, string value) { - _condition = condition; + _comparisonType = comparisonType; + _value = GetPaddedVersionString(value); + } + + public bool IsSatisfied(JsonElement context) + { + var value = GetPaddedVersionString(context.GetString() ?? string.Empty); + return Comparison.Compare(value, _value, _comparisonType); } - public bool IsSatisfiedBy(T value) + // https://docs.growthbook.io/lib/build-your-own#private-paddedversionstringinput-string + public static string GetPaddedVersionString(string version) { - if (value is IEnumerable strings) + // Remove build info and leading `v` if any + // Split version into parts (both core version numbers and pre-release tags) + // "v1.2.3-rc.1+build123" -> ["1","2","3","rc","1"] + var parts = ExtraVersionCharsRemovalRegex().Replace(version, "").Split(['.', '-'], StringSplitOptions.None); + + var builder = new StringBuilder(); + // Left pad each numeric part with spaces so string comparisons will work ("9">"10", but " 9"<"10") + int i = 0; + for (; i < parts.Length; i++) { - foreach (var elem in strings) + if (StartsWithNumberRegex().IsMatch(parts[i])) { - if (_condition.IsSatisfiedBy(elem)) - { - return true; - } + var padding = 5 - parts[i].Length; + builder.Append(' ', padding > 0 ? padding : 0); } + + builder.Append(parts[i]); + builder.Append('-'); } - if (value is IEnumerable doubles) + // handle not full versions (like 1.0) + if (i < 3) { - foreach (var elem in doubles) + for (; i < 3; i++) { - if (_condition.IsSatisfiedBy(elem)) - { - return true; - } + builder.Append(" 0"); + builder.Append('-'); } } - if (value is IEnumerable ints) + // If it's SemVer without a pre-release, add `~` to the end + // ["1","0","0"] -> ["1","0","0","~"] + // "~" is the largest ASCII character, so this will make "1.0.0" greater than "1.0.0-beta" for example + if (i == 3) + { + builder.Append('~'); + } + + return builder.ToString(); + } + + [GeneratedRegex("(^v|\\+.*$)")] + private static partial Regex ExtraVersionCharsRemovalRegex(); + [GeneratedRegex("^[0-9]+")] + private static partial Regex StartsWithNumberRegex(); +} + +internal class ElemMatchFilter : IFilter +{ + private readonly IFilter _condition; + + public ElemMatchFilter(IFilter condition) + { + _condition = condition; + } + + public bool IsSatisfied(JsonElement context) + { + if (context.ValueKind == JsonValueKind.Array) { - foreach (var elem in ints) + foreach (var elem in context.EnumerateArray()) { - if (_condition.IsSatisfiedBy(elem)) + if (_condition.IsSatisfied(elem)) { return true; } @@ -466,112 +501,170 @@ public bool IsSatisfiedBy(T value) } } -internal class SizeFilter : IFilteringCondition +internal class SizeFilter : IFilter { - private readonly int _size; + private readonly IFilter _inner; - public SizeFilter(int size) + public SizeFilter(IFilter inner) { - _size = size; + _inner = inner; } - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { - if (value is IEnumerable strings) - { - return strings.Count() == _size; - } - - if (value is IEnumerable doubles) + if (context.ValueKind == JsonValueKind.Array) { - return doubles.Count() == _size; - } - - if (value is IEnumerable ints) - { - return ints.Count() == _size; - } - - if (value is ICollection objects) - { - return objects.Count == _size; + return _inner.IsSatisfied(JsonSerializer.SerializeToElement(context.GetArrayLength())); } return false; } } -internal class AllFilter : IFilteringCondition +internal class AllFilter : IFilter { - private readonly IEnumerable _conditions; + private readonly IEnumerable _conditions; - public AllFilter(IEnumerable conditions) + public AllFilter(IEnumerable conditions) { _conditions = conditions; } - public bool IsSatisfiedBy(T value) + public bool IsSatisfied(JsonElement context) { - if (value is IEnumerable strings) + if (context.ValueKind == JsonValueKind.Array) { foreach (var condition in _conditions) { bool any = false; - foreach (var elem in strings) + foreach (var elem in context.EnumerateArray()) { - if (!condition.IsSatisfiedBy(elem)) + if (condition.IsSatisfied(elem)) { any = true; } } - if (!any) { return false; } } + + return true; } - if (value is IEnumerable doubles) + return false; + } +} + +internal class ArrayFilter : IFilter +{ + private readonly IEnumerable _conditions; + public ArrayFilter(IEnumerable conditions) + { + _conditions = conditions; + } + public bool IsSatisfied(JsonElement context) + { + if (context.ValueKind == JsonValueKind.Array) { + var enumerator = context.EnumerateArray(); foreach (var condition in _conditions) { - bool any = false; - foreach (var elem in doubles) + if (!enumerator.MoveNext()) { - if (!condition.IsSatisfiedBy(elem)) - { - any = true; - } + return false; } - - if (!any) + if (!condition.IsSatisfied(enumerator.Current)) { return false; } } + return !enumerator.MoveNext(); } + return false; + } +} + +internal class TypeFilter : IFilter +{ + private readonly string _type; + public TypeFilter(string type) + { + _type = type; + } + public bool IsSatisfied(JsonElement context) + { + return context.ValueKind switch + { + JsonValueKind.String => _type == "string", + JsonValueKind.Number => _type == "number", + JsonValueKind.True or JsonValueKind.False => _type == "boolean", + JsonValueKind.Null => _type == "null", + JsonValueKind.Object => _type == "object", + JsonValueKind.Array => _type == "array", + JsonValueKind.Undefined=> _type == "undefined", + _ => false, + }; + } +} - if (value is IEnumerable ints) +internal class PropertyFilter : IFilter +{ + private readonly string[] _path; + private readonly IFilter _condition; + public PropertyFilter(string propertyName, IFilter condition) + { + _path = propertyName.Split("."); + _condition = condition; + } + public bool IsSatisfied(JsonElement context) + { + JsonElement target = context; + foreach (var segment in _path) { - foreach (var condition in _conditions) + if (target.ValueKind != JsonValueKind.Object) { - bool any = false; - foreach (var elem in ints) - { - if (!condition.IsSatisfiedBy(elem)) - { - any = true; - } - } + target = new JsonElement(); // kind = undefined + break; + } - if (!any) + var found = false; + foreach (var property in target.EnumerateObject()) + { + if (property.Name.Equals(segment, StringComparison.OrdinalIgnoreCase)) { - return false; + target = property.Value; + found = true; + break; } } + if (!found) + { + target = new JsonElement(); // kind = undefined + } } - return false; + return _condition.IsSatisfied(target); + } +} + +internal class RegexFilter : IFilter +{ + private readonly Regex? _regex; + public RegexFilter(string pattern) + { + try + { + _regex = new Regex(pattern); + } + catch(RegexParseException) + { + _regex = null; + } + } + public bool IsSatisfied(JsonElement context) + { + return _regex?.IsMatch(context.GetString() ?? string.Empty) ?? false; } } diff --git a/src/Excos.Options.GrowthBook/GrowthBookFeatureCache.cs b/src/Excos.Options.GrowthBook/GrowthBookFeatureCache.cs index bdae6bc..e230818 100644 --- a/src/Excos.Options.GrowthBook/GrowthBookFeatureCache.cs +++ b/src/Excos.Options.GrowthBook/GrowthBookFeatureCache.cs @@ -85,7 +85,7 @@ private async Task RequestFeaturesAsync() _cacheExpiration = DateTimeOffset.UtcNow + _options.CurrentValue.CacheDuration; - _logger.LogInformation("Loaded the following GrowthBook features: {features}", _cachedFeatures.Select(static f => $"{f.Name}[${f.Variants.Count}]")); + _logger.LogInformation("Loaded the following GrowthBook features: {features}", _cachedFeatures.Select(static f => $"{f.Name}[${f.Count}]")); } catch (Exception ex) { diff --git a/src/Excos.Options.GrowthBook/GrowthBookFeatureParser.cs b/src/Excos.Options.GrowthBook/GrowthBookFeatureParser.cs index 3e5d02d..cd83c27 100644 --- a/src/Excos.Options.GrowthBook/GrowthBookFeatureParser.cs +++ b/src/Excos.Options.GrowthBook/GrowthBookFeatureParser.cs @@ -2,8 +2,8 @@ // Licensed under the Apache License, Version 2.0 using System.Text.Json; +using Excos.Options.Abstractions; using Excos.Options.Abstractions.Data; -using Excos.Options.Utils; using static Excos.Options.GrowthBook.JsonConfigureOptions; namespace Excos.Options.GrowthBook @@ -21,38 +21,37 @@ public static IEnumerable ConvertFeaturesToExcos(IDictionary(rule.Namespace[1].GetDouble(), rule.Namespace[2].GetDouble(), RangeType.IncludeBoth) : (Range?)null; + var namespaceRange = namespaceId is not null ? new Range(rule.Namespace[1].GetDouble(), rule.Namespace[2].GetDouble(), RangeType.IncludeBoth) : new Range(); - var filters = FilterParser.ParseFilters(rule.Condition); + var filters = new List { FilterParser.ParseFilters(rule.Condition) }; if (namespaceId is not null) { - filters[rule.HashAttribute] = filters.TryGetValue(rule.HashAttribute, out var filter) - ? new NamespaceInclusiveFilter(namespaceId, namespaceRange!.Value, filter) - : new NamespaceInclusiveFilter(namespaceId, namespaceRange!.Value, null); + filters.Add(new NamespaceFilteringCondition(rule.HashAttribute, namespaceId, namespaceRange)); } if (rule.Force.ValueKind != JsonValueKind.Undefined) { + var allocationFilter = new AllocationFilteringCondition( + rule.HashAttribute, + rule.Seed ?? rule.Key ?? gbFeature.Key, + rule.HashVersion == 1 ? GrowthBookHash.V1 : GrowthBookHash.V2, + Allocation.Percentage(rule.Coverage * 100) + ); + filters.Add(allocationFilter); var variant = new Variant { - Id = $":{ruleIdx}", - Allocation = Allocation.Percentage(rule.Coverage * 100), + Id = $"{rule.Key ?? gbFeature.Key}:Force{ruleIdx}", Configuration = new JsonConfigureOptions(gbFeature.Key, rule.Force), Priority = ruleIdx, - AllocationUnit = rule.HashAttribute, - AllocationSalt = rule.Seed ?? rule.Key, - AllocationHash = rule.HashVersion == 1 ? GrowthBookHash.V1 : GrowthBookHash.V2, }; - variant.Filters.AddRange(filters.Select(kvp => new Filter { PropertyName = kvp.Key, Conditions = { kvp.Value } })); - feature.Variants.Add(variant); + variant.Filters = filters; + feature.Add(variant); } else if (rule.Variations.ValueKind == JsonValueKind.Array && rule.Weights != null) { @@ -67,18 +66,21 @@ public static IEnumerable ConvertFeaturesToExcos(IDictionary new Filter { PropertyName = kvp.Key, Conditions = { kvp.Value } })); - feature.Variants.Add(variant); + // copy filters to allow outer collection reuse + variant.Filters = new List(filters) { allocationFilter }; + feature.Add(variant); } } diff --git a/src/Excos.Options.GrowthBook/Readme.md b/src/Excos.Options.GrowthBook/Readme.md index d1f5169..785fbce 100644 --- a/src/Excos.Options.GrowthBook/Readme.md +++ b/src/Excos.Options.GrowthBook/Readme.md @@ -2,4 +2,6 @@ Integration of Excos.Options library with the [GrowthBook](https://growthbook.io/) experimentation service. -See the GitHub repository for more information: [Excos](https://github.com/manio143/excos). +WARNING: this client SDK is not 100% conforming to the specification of GrowthBook SDKs. See unit tests for exceptions details. Main difference is that string comparison is case insensitive. + +See the GitHub repository for more information: [Excos](https://github.com/excos-platform/config-client). diff --git a/src/Excos.Options.Tests/ConfigurationBasedFeaturesTest.cs b/src/Excos.Options.Tests/ConfigurationBasedFeaturesTest.cs index 80fcf2e..4c489ed 100644 --- a/src/Excos.Options.Tests/ConfigurationBasedFeaturesTest.cs +++ b/src/Excos.Options.Tests/ConfigurationBasedFeaturesTest.cs @@ -191,6 +191,5 @@ private class TestOptions { public int Size { get; set; } public string Label { get; set; } = string.Empty; - public FeatureMetadata? Metadata { get; set; } } } diff --git a/src/Excos.Options.Tests/Contextual/ContextReceiverTests.cs b/src/Excos.Options.Tests/Contextual/ContextReceiverTests.cs deleted file mode 100644 index 1ff76b5..0000000 --- a/src/Excos.Options.Tests/Contextual/ContextReceiverTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using Excos.Options.Contextual; -using Excos.Options.Utils; -using Microsoft.Extensions.Options.Contextual; -using Xunit; - -namespace Excos.Options.Tests.Contextual; - -public class ContextReceiverTests -{ - /// - /// Ensure there is no randomness when handling the same identifier. - /// - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("abc")] - public void Allocation_Receive_WithSameIdentifier_ReturnsTheSameAllocation(string value) - { - ContextWithIdentifier context1 = new() { Identifier = value }; - using AllocationContextReceiver receiver1 = PopulateAllocationReceiver(context1, nameof(context1.Identifier)); - ContextWithIdentifier context2 = new() { Identifier = value }; - using AllocationContextReceiver receiver2 = PopulateAllocationReceiver(context2, nameof(context2.Identifier)); - - var allocationSpot1 = receiver1.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - var allocationSpot2 = receiver2.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - - Assert.Equal(allocationSpot1, allocationSpot2); - } - - /// - /// Ensure there is difference between values. - /// - [Fact] - public void Allocation_Receive_WithDifferentIdentifier_ReturnsDifferentAllocation() - { - ContextWithIdentifier context1 = new() { Identifier = "abc" }; - using AllocationContextReceiver receiver1 = PopulateAllocationReceiver(context1, nameof(context1.Identifier)); - ContextWithIdentifier context2 = new() { Identifier = "def" }; - using AllocationContextReceiver receiver2 = PopulateAllocationReceiver(context2, nameof(context2.Identifier)); - - var allocationSpot1 = receiver1.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - var allocationSpot2 = receiver2.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - - Assert.NotEqual(allocationSpot1, allocationSpot2); - } - - [Fact] - public void Allocation_Receive_WithAnyIdProperty_ReturnsTheSameAllocation() - { - const string id = "abc"; - ContextWithIdentifier context1 = new() { Identifier = id }; - using AllocationContextReceiver receiver1 = PopulateAllocationReceiver(context1, nameof(context1.Identifier)); - ContextWithIdentifier context2 = new() { UserId = id }; - using AllocationContextReceiver receiver2 = PopulateAllocationReceiver(context2, nameof(context2.UserId)); - ContextWithIdentifier context3 = new() { SessionId = id }; - using AllocationContextReceiver receiver3 = PopulateAllocationReceiver(context3, nameof(context3.SessionId)); - - var allocationSpot1 = receiver1.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - var allocationSpot2 = receiver2.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - var allocationSpot3 = receiver3.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - - Assert.Equal(allocationSpot1, allocationSpot2); - Assert.Equal(allocationSpot1, allocationSpot3); - } - - [Fact] - public void Receive_WhenAskedAboutNonExistentProperty_ReturnsSameAllocationAsEmpty() - { - ContextWithIdentifier context1 = new() { Identifier = "x", UserId = "y", SessionId = "z" }; - using AllocationContextReceiver receiver1 = PopulateAllocationReceiver(context1, "AnonymousId"); - ContextWithIdentifier context2 = new(); - using AllocationContextReceiver receiver2 = PopulateAllocationReceiver(context2, nameof(context2.UserId)); - - var allocationSpot1 = receiver1.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - var allocationSpot2 = receiver2.GetIdentifierAllocationSpot(XxHashAllocation.Instance); - - Assert.Equal(allocationSpot1, allocationSpot2); - } - - private static AllocationContextReceiver PopulateAllocationReceiver(TContext context, string propertyName) where TContext : IOptionsContext - { - AllocationContextReceiver receiver = AllocationContextReceiver.Get(propertyName, salt: string.Empty); - context.PopulateReceiver(receiver); - return receiver; - } -} diff --git a/src/Excos.Options.Tests/Excos.Options.Tests.csproj b/src/Excos.Options.Tests/Excos.Options.Tests.csproj index 88de29c..8b5b116 100644 --- a/src/Excos.Options.Tests/Excos.Options.Tests.csproj +++ b/src/Excos.Options.Tests/Excos.Options.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Excos.Options.Tests/OptionsBasedFeaturesTests.cs b/src/Excos.Options.Tests/OptionsBasedFeaturesTests.cs index 3bfff49..82be675 100644 --- a/src/Excos.Options.Tests/OptionsBasedFeaturesTests.cs +++ b/src/Excos.Options.Tests/OptionsBasedFeaturesTests.cs @@ -4,7 +4,6 @@ using Excos.Options.Abstractions; using Excos.Options.Abstractions.Data; using Excos.Options.Contextual; -using Excos.Options.Filtering; using Excos.Options.Providers; using Excos.Options.Tests.Contextual; using Microsoft.Extensions.DependencyInjection; @@ -16,350 +15,35 @@ namespace Excos.Options.Tests; public class OptionsBasedFeaturesTests { - private IServiceProvider BuildServiceProvider(Action> configure, Action? additionalConfig = null) + private IServiceProvider BuildServiceProvider(Action>> configure, Action? additionalConfig = null) { var services = new ServiceCollection(); services.ConfigureExcos("Test"); services.AddSingleton(); - configure(services.AddOptions()); + configure(services.AddOptions>()); additionalConfig?.Invoke(services); return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); } - [Fact] - public async Task FeatureMetadataIsPopulated() - { - var provider = BuildServiceProvider(o => o.Configure(features => features.Add(new Feature - { - Name = "TestFeature", - ProviderName = "Tests", - Filters = - { - new Filter - { - PropertyName = "UserId", - Conditions = - { - new StringFilteringCondition("user1"), - }, - }, - }, - Variants = - { - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "EmptyVariant" - } - } - }))); - - var contextual = provider.GetRequiredService>(); - var optionsWithUser = await contextual.GetAsync(new ContextWithIdentifier { UserId = "user1" }, default); - var optionsWithoutUser = await contextual.GetAsync(new ContextWithIdentifier(), default); - - Assert.Null(optionsWithoutUser.Metadata); - - Assert.NotNull(optionsWithUser.Metadata); - var metadata = Assert.Single(optionsWithUser.Metadata.Features); - Assert.Equal("TestFeature", metadata.FeatureName); - Assert.Equal("Tests", metadata.FeatureProvider); - Assert.Equal("EmptyVariant", metadata.VariantId); - } - - [Fact] - public async Task MultipleMatchingVariants_MostFiltersIsChosen() - { - var provider = BuildServiceProvider(o => o.Configure(features => features.Add(new Feature - { - Name = "TestFeature", - ProviderName = "Tests", - Variants = - { - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "NoFilter" - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Filtered1", - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - } - } - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Filtered2", - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - }, - new Filter - { - PropertyName = "AgeGroup", - Conditions = { new RangeFilteringCondition(new Range(1,2,RangeType.IncludeBoth)) } - } - } - } - } - }))); - - var contextual = provider.GetRequiredService>(); - var options = await contextual.GetAsync(new ContextWithIdentifier { Market = "US", AgeGroup = 1 }, default); - - Assert.NotNull(options.Metadata); - var metadata = Assert.Single(options.Metadata.Features); - Assert.Equal("TestFeature", metadata.FeatureName); - Assert.Equal("Filtered2", metadata.VariantId); - } - - [Fact] - public async Task MultipleMatchingVariants_PriorityOverMostFiltersIsChosen() - { - var provider = BuildServiceProvider(o => o.Configure(features => features.Add(new Feature - { - Name = "TestFeature", - ProviderName = "Tests", - Variants = - { - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Priority", - Priority = 1 - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Filtered1", - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - } - } - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Filtered2", - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - }, - new Filter - { - PropertyName = "AgeGroup", - Conditions = { new RangeFilteringCondition(new Range(1,2,RangeType.IncludeBoth)) } - } - } - } - } - }))); - - var contextual = provider.GetRequiredService>(); - var options = await contextual.GetAsync(new ContextWithIdentifier { Market = "US", AgeGroup = 1 }, default); - - Assert.NotNull(options.Metadata); - var metadata = Assert.Single(options.Metadata.Features); - Assert.Equal("TestFeature", metadata.FeatureName); - Assert.Equal("Priority", metadata.VariantId); - } - [Fact] public async Task MultipleMatchingVariants_LowestPriorityIsChosen() { - var provider = BuildServiceProvider(o => o.Configure(features => features.Add(new Feature - { - Name = "TestFeature", - ProviderName = "Tests", - Variants = - { - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "PriorityOnly", - Priority = 2 - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "FilteredWithPriority", - Priority = 1, - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - } - } - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Filtered2", - Priority = 3, - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - }, - new Filter - { - PropertyName = "AgeGroup", - Conditions = { new RangeFilteringCondition(new Range(1,2,RangeType.IncludeBoth)) } - } - } - } - } - }))); - - var contextual = provider.GetRequiredService>(); - var options = await contextual.GetAsync(new ContextWithIdentifier { Market = "US", AgeGroup = 1 }, default); - - Assert.NotNull(options.Metadata); - var metadata = Assert.Single(options.Metadata.Features); - Assert.Equal("TestFeature", metadata.FeatureName); - Assert.Equal("FilteredWithPriority", metadata.VariantId); - } - - [Fact] - public async Task MultipleMatchingVariants_WithSamePriorityMostFiltersIsChosen() - { - var provider = BuildServiceProvider(o => o.Configure(features => features.Add(new Feature - { - Name = "TestFeature", - ProviderName = "Tests", - Variants = - { - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "PriorityOnly", - Priority = 2 - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Filtered1", - Priority = 1, - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - } - } - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "Filtered2", - Priority = 1, - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - }, - new Filter - { - PropertyName = "AgeGroup", - Conditions = { new RangeFilteringCondition(new Range(1,2,RangeType.IncludeBoth)) } - } - } - } - } - }))); - - var contextual = provider.GetRequiredService>(); - var options = await contextual.GetAsync(new ContextWithIdentifier { Market = "US", AgeGroup = 1 }, default); - - Assert.NotNull(options.Metadata); - var metadata = Assert.Single(options.Metadata.Features); - Assert.Equal("TestFeature", metadata.FeatureName); - Assert.Equal("Filtered2", metadata.VariantId); - } - - [Fact] - public async Task WithOverride_ChoosesOverride() - { - var provider = BuildServiceProvider(o => o.Configure(features => features.Add(new Feature - { - Name = "TestFeature", - ProviderName = "Tests", - Variants = - { - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "WW", - }, - new Variant - { - Allocation = Allocation.Percentage(100), - Configuration = new NullConfigureOptions(), - Id = "US", - Filters = - { - new Filter - { - PropertyName = "Market", - Conditions = { new StringFilteringCondition("US") } - } - } - } - } - })), services => - { - services.AddSingleton(new TestOverride("TestFeature", "US")); - }); + var provider = BuildServiceProvider(o => o.BuildFeature("TestFeature", "Tests") + .WithFilter(nameof(ContextWithIdentifier.Market)).Matches("US").SaveFilter() + .Rollout(100, (options, _) => options.Label = "XX") + .Configure(f => f.Last().Priority = 2) + .Rollout(100, (options, _) => options.Label = "YY") + .Configure(f => f.Last().Priority = 1) + .Save()); var contextual = provider.GetRequiredService>(); - var options = await contextual.GetAsync(new ContextWithIdentifier { Market = "PL" }, default); + var context = new ContextWithIdentifier { Market = "US", AgeGroup = 1 }; + var options = await contextual.GetAsync(context, default); + var variants = provider.GetRequiredService().EvaluateFeaturesAsync(context, default).ToEnumerable().ToList(); - Assert.NotNull(options.Metadata); - var metadata = Assert.Single(options.Metadata.Features); - Assert.Equal("TestFeature", metadata.FeatureName); - Assert.Equal("US", metadata.VariantId); - Assert.True(metadata.IsOverridden); - Assert.Equal(nameof(TestOverride), metadata.OverrideProviderName); + var metadata = Assert.Single(variants); + Assert.Equal("TestFeature:Rollout_1", metadata.Id); } [Fact] @@ -370,53 +54,50 @@ public async Task ExtensionsBuilder_SetsUpFeatureEasily() .Rollout(75, (options, _) => options.Label = "XX") .Save() .BuildFeature("TestExperiment") - .Configure(b => b.AllocationUnit = nameof(ContextWithIdentifier.SessionId)) .WithFilter(nameof(ContextWithIdentifier.AgeGroup)).InRange(new Range(0, 5, RangeType.IncludeBoth)).SaveFilter() - .ABExperiment((options, _) => options.Length = 5, (options, _) => options.Length = 10) + .ABExperiment((options, _) => options.Length = 5, (options, _) => options.Length = 10, nameof(ContextWithIdentifier.SessionId)) .Save()); var contextual = provider.GetRequiredService>(); - var options = await contextual.GetAsync(new ContextWithIdentifier { Market = "US", AgeGroup = 1, UserId = "test", SessionId = "testSession" }, default); + var context = new ContextWithIdentifier { Market = "US", AgeGroup = 1, UserId = "test", SessionId = "testSession" }; + var options = await contextual.GetAsync(context, default); + var variants = provider.GetRequiredService().EvaluateFeaturesAsync(context, default).ToEnumerable().ToList(); - Assert.NotNull(options.Metadata); - Assert.Equal(2, options.Metadata.Features.Count); - Assert.Equal("TestFeature", options.Metadata.Features.ElementAt(0).FeatureName); - Assert.Equal("TestExperiment", options.Metadata.Features.ElementAt(1).FeatureName); - Assert.Equal("B", options.Metadata.Features.ElementAt(1).VariantId); + Assert.Equal(2, variants.Count); + Assert.Equal("TestFeature:Rollout_0", variants.ElementAt(0).Id); + Assert.Equal("TestExperiment:B_1", variants.ElementAt(1).Id); Assert.Equal("XX", options.Label); Assert.Equal(10, options.Length); } - private class TestOptions + [Fact] + public async Task DistinctFilters_OneOptionIsChosen() { - public int Length { get; set; } - public string Label { get; set; } = string.Empty; - public FeatureMetadata? Metadata { get; set; } - } + var provider = BuildServiceProvider(o => o.BuildFeature("Test1") + .WithFilter(nameof(ContextWithIdentifier.Market)).Matches("US").Or().Matches("UK").SaveFilter() + .Rollout(100, (options, _) => options.Label = "X1") + .Save() + .BuildFeature("Test2") + .WithFilter(nameof(ContextWithIdentifier.Market)).Matches("EU").SaveFilter() + .Rollout(75, (options, _) => options.Label = "X2") + .Save()); - private class TestOverride : IFeatureVariantOverride - { - private readonly string _featureName; - private readonly string _variantId; + var contextual = provider.GetRequiredService>(); - public TestOverride(string featureName, string variantId) - { - _featureName = featureName; - _variantId = variantId; - } + var options = await contextual.GetAsync(new ContextWithIdentifier { Market = "US" }, default); + Assert.Equal("X1", options.Label); - public ValueTask TryOverrideAsync(Feature feature, TContext optionsContext, CancellationToken cancellationToken) where TContext : IOptionsContext - { - VariantOverride? result = feature.Name == _featureName - ? new VariantOverride - { - Id = _variantId, - OverrideProviderName = nameof(TestOverride), - } - : null; + options = await contextual.GetAsync(new ContextWithIdentifier { Market = "UK" }, default); + Assert.Equal("X1", options.Label); - return new ValueTask(result); - } + options = await contextual.GetAsync(new ContextWithIdentifier { Market = "EU" }, default); + Assert.Equal("X2", options.Label); + } + + private class TestOptions + { + public int Length { get; set; } + public string Label { get; set; } = string.Empty; } } diff --git a/src/Excos.Options/Abstractions/Data/Feature.cs b/src/Excos.Options/Abstractions/Data/Feature.cs index df1f979..c572b88 100644 --- a/src/Excos.Options/Abstractions/Data/Feature.cs +++ b/src/Excos.Options/Abstractions/Data/Feature.cs @@ -1,71 +1,20 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 -using Excos.Options.Utils; +using System.Collections.ObjectModel; namespace Excos.Options.Abstractions.Data; /// /// Description of a feature. /// -public class Feature +public class Feature : KeyedCollection { /// /// Name of the feature. /// public required string Name { get; set; } - /// - /// Name of the feature provider. - /// This is used for metadata to help determine the source of feature configuration. - /// - public required string ProviderName { get; set; } - - /// - /// Whether the feature is active. - /// - public bool Enabled { get; set; } = true; - - /// - /// Collection of filters which will be checked against a user provided context. - /// - public FilterCollection Filters { get; } = new(); - - /// - /// Collection of variants for this feature. - /// - public VariantCollection Variants { get; } = new(); - - /// - /// Name of the property in context which should be used for allocation calculation for this feature. - /// If not set, the value is used. - /// - public string? AllocationUnit { get; set; } - - /// - /// Hashing algorithm used for variant allocation calculations. - /// - public IAllocationHash AllocationHash { get; set; } = XxHashAllocation.Instance; - - /// - /// Salt used for variant allocation calculations. - /// By default it's just the name of the provider and feature. - /// - public string Salt - { - get - { - if (_salt == null) - { - // IMPORTANT: do not change this going forward - // as it will break any experiments using the default salt - _salt = $"{ProviderName}_{Name}"; - } - - return _salt; - } - set => _salt = value; - } - - private string? _salt = null; + /// + protected override string GetKeyForItem(Variant item) => item.Id; } diff --git a/src/Excos.Options/Abstractions/Data/FeatureCollection.cs b/src/Excos.Options/Abstractions/Data/FeatureCollection.cs deleted file mode 100644 index 5a392a7..0000000 --- a/src/Excos.Options/Abstractions/Data/FeatureCollection.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using System.Collections.ObjectModel; - -namespace Excos.Options.Abstractions.Data; - -/// -/// Collection of features, indexed by their names. -/// -/// -/// Currently used mainly for Options based configuration. -/// -public class FeatureCollection : KeyedCollection -{ - /// - /// - /// Feature names should be unique per provider which owns a collection. - /// - protected override string GetKeyForItem(Feature item) => item.Name; -} diff --git a/src/Excos.Options/Abstractions/Data/Filter.cs b/src/Excos.Options/Abstractions/Data/Filter.cs deleted file mode 100644 index d37d615..0000000 --- a/src/Excos.Options/Abstractions/Data/Filter.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -namespace Excos.Options.Abstractions.Data; - -/// -/// A feature filter applied over a user provided context. -/// -public class Filter -{ - /// - /// Name of the property this filter applies to. - /// - public required string PropertyName { get; set; } - - /// - /// List of conditions representing this filter. - /// The filter is satisfied if any of them is matched. - /// - public List Conditions { get; } = new(); - - /// - /// Checks if the values satisfies the filter based on the conditions. - /// At least one condition must be satisfied. - /// - /// Value. - /// Type of the value. - /// True if filter is satisfied, false otherwise. - public bool IsSatisfiedBy(T value) - { - if (Conditions.Count == 0) - { - return true; - } - - foreach (var condition in Conditions) - { - if (condition.IsSatisfiedBy(value)) - { - return true; - } - } - - return false; - } -} diff --git a/src/Excos.Options/Abstractions/Data/FilterCollection.cs b/src/Excos.Options/Abstractions/Data/FilterCollection.cs deleted file mode 100644 index 839e91c..0000000 --- a/src/Excos.Options/Abstractions/Data/FilterCollection.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using System.Collections.ObjectModel; - -namespace Excos.Options.Abstractions.Data; - -/// -/// Keyed collection of filters, indexed by their property names. -/// -public class FilterCollection : KeyedCollection -{ - /// - protected override string GetKeyForItem(Filter item) => item.PropertyName; -} diff --git a/src/Excos.Options/Abstractions/Data/Range.cs b/src/Excos.Options/Abstractions/Data/Range.cs index 432d962..c0d7714 100644 --- a/src/Excos.Options/Abstractions/Data/Range.cs +++ b/src/Excos.Options/Abstractions/Data/Range.cs @@ -41,7 +41,7 @@ public Range(T start, T end, RangeType type) /// End of the range. /// public T End { get; } - + /// /// Type of the range in terms of inclusivity of beginning and end. /// diff --git a/src/Excos.Options/Abstractions/Data/Variant.cs b/src/Excos.Options/Abstractions/Data/Variant.cs index 2bea06c..d9139e2 100644 --- a/src/Excos.Options/Abstractions/Data/Variant.cs +++ b/src/Excos.Options/Abstractions/Data/Variant.cs @@ -1,8 +1,6 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 -using Excos.Options.Utils; - namespace Excos.Options.Abstractions.Data; /// @@ -11,20 +9,16 @@ namespace Excos.Options.Abstractions.Data; public class Variant { /// - /// Identifier of the variant. + /// Unique identifier of the variant. /// Mainly used in experiment analysis. /// public required string Id { get; set; } - /// - /// Allocation of the variant a range of users who otherwise satisfy the filters. - /// - public required Allocation Allocation { get; set; } - /// /// Collection of filters for this variant. + /// When all filters are satisfied, the variant is considered a match. /// - public FilterCollection Filters { get; } = new(); + public IEnumerable Filters { get; set; } = Enumerable.Empty(); /// /// Options object configuration function. @@ -36,23 +30,6 @@ public class Variant /// An optional priority. /// If more than one variant is matched by filters and allocation, /// the priority (lowest first) is used to determine which variant to pick. - /// If priority is not specified, the first variant with the highest number of filtered properties will be chosen. - /// - public int? Priority { get; set; } - - /// - /// Name of the property in context which should be used for allocation calculation for this variant. - /// Overridable for compatibility with external providers. Ideally you should use the same allocation unit for all variants on the feature level. - /// - public string? AllocationUnit { get; set; } - - /// - /// Hashing algorithm used for variant allocation calculations. - /// - public IAllocationHash AllocationHash { get; set; } = XxHashAllocation.Instance; - - /// - /// Overridable salt used for variant allocation calculations. /// - public string? AllocationSalt { get; set; } + public int Priority { get; set; } } diff --git a/src/Excos.Options/Abstractions/Data/VariantCollection.cs b/src/Excos.Options/Abstractions/Data/VariantCollection.cs deleted file mode 100644 index ce38cf8..0000000 --- a/src/Excos.Options/Abstractions/Data/VariantCollection.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using System.Collections.ObjectModel; - -namespace Excos.Options.Abstractions.Data; - -/// -/// Keyed collection of variants, indexed by their IDs. -/// -public class VariantCollection : KeyedCollection -{ - /// - protected override string GetKeyForItem(Variant item) => item.Id; -} diff --git a/src/Excos.Options/Abstractions/Data/VariantOverride.cs b/src/Excos.Options/Abstractions/Data/VariantOverride.cs deleted file mode 100644 index 7299d97..0000000 --- a/src/Excos.Options/Abstractions/Data/VariantOverride.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -namespace Excos.Options.Abstractions.Data; - -/// -/// Metadata of a variant override. -/// -public class VariantOverride -{ - /// - /// Id of the variant which is to be chosen. - /// - public required string Id { get; set; } - - /// - /// Name of the override provider. - /// - public required string OverrideProviderName { get; set; } -} diff --git a/src/Excos.Options/Abstractions/IFeatureVariantOverride.cs b/src/Excos.Options/Abstractions/IFeatureVariantOverride.cs deleted file mode 100644 index 6f78b07..0000000 --- a/src/Excos.Options/Abstractions/IFeatureVariantOverride.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using Excos.Options.Abstractions.Data; - -using Microsoft.Extensions.Options.Contextual; - -namespace Excos.Options.Abstractions; - -/// -/// A source of variant overrides. -/// -public interface IFeatureVariantOverride -{ - /// - /// Checks if the variant for has been overridden for the given context. - /// - /// - /// You can use this interface to implement override providers from various sources. - /// For example in ASP.NET context there could be an override based on query parameters of the request. - /// Or you can override the variant based on a list of test user ids. - /// If there's multiple overrides registered, the first one which returns a value will be applied. - /// - /// Experiment. - /// Context. - /// Cancellation token. - /// Variant override that should be used for the , or null if no override is made. - ValueTask TryOverrideAsync(Feature feature, TContext optionsContext, CancellationToken cancellationToken) - where TContext : IOptionsContext; -} diff --git a/src/Excos.Options/Abstractions/IFilteringCondition.cs b/src/Excos.Options/Abstractions/IFilteringCondition.cs index a431264..e692b27 100644 --- a/src/Excos.Options/Abstractions/IFilteringCondition.cs +++ b/src/Excos.Options/Abstractions/IFilteringCondition.cs @@ -1,6 +1,8 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 +using Microsoft.Extensions.Options.Contextual; + namespace Excos.Options.Abstractions; /// @@ -9,13 +11,11 @@ namespace Excos.Options.Abstractions; public interface IFilteringCondition { /// - /// Checks whether the provided value satisfies a filtering condition. + /// Checks whether the provided context satisfies a filtering condition. /// - /// - /// Implementer may choose what types it supports and return false for all other ones. - /// /// Value. - /// Type of the value. + /// Context type. /// True if condition is satisfied, false otherwise. - bool IsSatisfiedBy(T value); + bool IsSatisfiedBy(TContext value) + where TContext : IOptionsContext; } diff --git a/src/Excos.Options/Contextual/AllocationContextReceiver.cs b/src/Excos.Options/Contextual/AllocationContextReceiver.cs deleted file mode 100644 index 4930c3e..0000000 --- a/src/Excos.Options/Contextual/AllocationContextReceiver.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using System.IO.Hashing; -using System.Runtime.InteropServices; -using Excos.Options.Abstractions; -using Excos.Options.Utils; -using Microsoft.Extensions.Options.Contextual.Provider; - -namespace Excos.Options.Contextual; - -/// -/// Context receiver for determining allocation spot based on context. -/// -[PrivatePool] -internal partial class AllocationContextReceiver : IOptionsContextReceiver, IDisposable -{ - private string _allocationUnit; - private string _salt; - private string _value = string.Empty; - - private AllocationContextReceiver(string allocationUnit, string salt) - { - _allocationUnit = allocationUnit; - _salt = salt; - } - - public void Receive(string key, T value) - { - if (string.Equals(key, _allocationUnit, StringComparison.InvariantCultureIgnoreCase)) - { - _value = value?.ToString() ?? string.Empty; - } - } - - /// - /// Compute an allocation spot (floating point value between 0 and 1) for the identifier from context. - /// - public double GetIdentifierAllocationSpot(IAllocationHash hash) - { - return hash.GetAllocationSpot(_salt, _value); - } - - public void Dispose() => Return(this); - - private void Clear() => _value = string.Empty; -} diff --git a/src/Excos.Options/Contextual/ConfigureContextualOptions.cs b/src/Excos.Options/Contextual/ConfigureContextualOptions.cs index 27cca94..f9a01f1 100644 --- a/src/Excos.Options/Contextual/ConfigureContextualOptions.cs +++ b/src/Excos.Options/Contextual/ConfigureContextualOptions.cs @@ -2,23 +2,21 @@ // Licensed under the Apache License, Version 2.0 using Excos.Options.Abstractions; -using Excos.Options.Utils; using Microsoft.Extensions.Options.Contextual.Provider; namespace Excos.Options.Contextual; -[PrivatePool] internal partial class ConfigureContextualOptions : IConfigureContextualOptions where TOptions : class { - private string _configurationSection; + private readonly string _configurationSection; - private ConfigureContextualOptions(string configurationSection) + public ConfigureContextualOptions(string configurationSection) { _configurationSection = configurationSection; } - public List ConfigureOptions { get; } = new(); + public List ConfigureOptions { get; } = new(8); public void Configure(TOptions options) { @@ -30,17 +28,5 @@ public void Configure(TOptions options) public void Dispose() { - foreach (var configureOptions in ConfigureOptions) - { - if (configureOptions is IPooledConfigureOptions pooled) - { - pooled.ReturnToPool(); - } - } - - Clear(); - Return(this); } - - private void Clear() => ConfigureOptions.Clear(); } diff --git a/src/Excos.Options/Contextual/ConfigureFeatureMetadata.cs b/src/Excos.Options/Contextual/ConfigureFeatureMetadata.cs deleted file mode 100644 index 2dedc1b..0000000 --- a/src/Excos.Options/Contextual/ConfigureFeatureMetadata.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using Excos.Options.Abstractions; -using Excos.Options.Utils; - -namespace Excos.Options.Contextual; - -[PrivatePool] -internal partial class ConfigureFeatureMetadata : IPooledConfigureOptions -{ - private FeatureMetadata? _metadata; - private string _propertyName; - - private ConfigureFeatureMetadata(FeatureMetadata? metadata, string propertyName) - { - _metadata = metadata; - _propertyName = propertyName; - } - - public void Configure(TOptions input, string section) where TOptions : class - { - var property = typeof(TOptions).GetProperty(_propertyName); - property?.SetValue(input, _metadata); - } - - public void ReturnToPool() => Return(this); -} diff --git a/src/Excos.Options/Contextual/FilteringContextReceiver.cs b/src/Excos.Options/Contextual/FilteringContextReceiver.cs deleted file mode 100644 index d8d1ec5..0000000 --- a/src/Excos.Options/Contextual/FilteringContextReceiver.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using Excos.Options.Abstractions.Data; -using Excos.Options.Utils; -using Microsoft.Extensions.Options.Contextual.Provider; - -namespace Excos.Options.Contextual; - -/// -/// Context receiver processes an options context -/// and allows us to later execute the filtering over any options context in somewhat efficient manner (no reflection needed). -///
-/// For each property we create a filtering closure which accepts a argument and executes its method. -/// The reason why we create closures is to wrap the generic call and allow the caller to the context receiver later to not care about the types of underlying properties. -///
-[PrivatePool] -internal partial class FilteringContextReceiver : IOptionsContextReceiver, IDisposable -{ - private Dictionary FilteringClosures { get; } = new(StringComparer.InvariantCultureIgnoreCase); - - private FilteringContextReceiver() { } - - public void Receive(string key, T value) - { - FilteringClosures[key] = FilteringClosure.Get(value); - } - - public bool Satisfies(FilterCollection filters) - { - // every filter must be satisfied (F1 AND F2 AND ...) - foreach (var filter in filters) - { - if (!FilteringClosures.TryGetValue(filter.PropertyName, out var closure) || !closure.Invoke(filter)) - { - return false; - } - } - - return true; - } - - public void Dispose() - { - foreach (var closure in FilteringClosures.Values) - { - closure.Dispose(); - } - - Return(this); - } - - private void Clear() => FilteringClosures.Clear(); -} - -internal abstract class FilteringClosure : IDisposable -{ - public abstract bool Invoke(Filter filter); - public abstract void Dispose(); -} - -[PrivatePool] -internal sealed partial class FilteringClosure : FilteringClosure -{ - private T _value; - - private FilteringClosure(T value) => _value = value; - - public override void Dispose() => Return(this); - public override bool Invoke(Filter filter) => filter.IsSatisfiedBy(_value); -} diff --git a/src/Excos.Options/Abstractions/IPooledConfigureOptions.cs b/src/Excos.Options/Contextual/IPooledConfigureOptions.cs similarity index 86% rename from src/Excos.Options/Abstractions/IPooledConfigureOptions.cs rename to src/Excos.Options/Contextual/IPooledConfigureOptions.cs index 886ea9e..0eee9de 100644 --- a/src/Excos.Options/Abstractions/IPooledConfigureOptions.cs +++ b/src/Excos.Options/Contextual/IPooledConfigureOptions.cs @@ -1,7 +1,9 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 -namespace Excos.Options.Abstractions; +using Excos.Options.Abstractions; + +namespace Excos.Options.Contextual; /// /// For configure options types which can be pooled, upon contextual configuration finish is notified that it can be returned to the pool. diff --git a/src/Excos.Options/Contextual/LoadContextualOptions.cs b/src/Excos.Options/Contextual/LoadContextualOptions.cs index 905628d..f9b3afe 100644 --- a/src/Excos.Options/Contextual/LoadContextualOptions.cs +++ b/src/Excos.Options/Contextual/LoadContextualOptions.cs @@ -1,9 +1,6 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 -using Excos.Options.Abstractions; -using Excos.Options.Abstractions.Data; -using Microsoft.Extensions.Options; using Microsoft.Extensions.Options.Contextual; using Microsoft.Extensions.Options.Contextual.Provider; @@ -14,201 +11,42 @@ internal class LoadContextualOptions : ILoadContextualOptions _featureProviders; - private readonly IEnumerable _variantOverrides; - private readonly IOptionsMonitor _options; - private static readonly Lazy OptionsMetadataPropertyName = new(TryGetMetadataPropertyName, LazyThreadSafetyMode.PublicationOnly); + private readonly IFeatureEvaluation _featureEvaluation; public LoadContextualOptions( string? name, string configurationSection, - IEnumerable featureProviders, - IEnumerable variantOverrides, - IOptionsMonitor options) + IFeatureEvaluation featureEvaluation) { _name = name; _configurationSection = configurationSection; - _featureProviders = featureProviders; - _variantOverrides = variantOverrides; - _options = options; + _featureEvaluation = featureEvaluation; } - public ValueTask> LoadAsync(string name, in TContext context, CancellationToken cancellationToken) where TContext : IOptionsContext + public ValueTask> LoadAsync(string name, in TContext context, CancellationToken cancellationToken) + where TContext : IOptionsContext { ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(context); if (_name == null || name == _name) { - return LoadExperimentAsync(context, cancellationToken); + return GetConfigurationForFeaturesAsync(context, cancellationToken); } return new ValueTask>(NullConfigureContextualOptions.GetInstance()); } - private async ValueTask> LoadExperimentAsync(TContext context, CancellationToken cancellationToken) where TContext : IOptionsContext - { - var configure = ConfigureContextualOptions.Get(_configurationSection); - - using var filteringReceiver = FilteringContextReceiver.Get(); - context.PopulateReceiver(filteringReceiver); - - // only instantiate metadata if expected by options type - var optionsMetadataPropertyName = OptionsMetadataPropertyName.Value; - FeatureMetadata? metadataCollection = optionsMetadataPropertyName != null ? new() : null; - - foreach (var provider in _featureProviders) - { - var features = await provider.GetFeaturesAsync(cancellationToken).ConfigureAwait(false); - - foreach (var feature in features) - { - if (!feature.Enabled) - { - continue; - } - - if (!filteringReceiver.Satisfies(feature.Filters)) - { - continue; - } - - var variantOverride = await TryGetVariantOverrideAsync(feature, context, cancellationToken).ConfigureAwait(false); - - if (variantOverride != null) - { - var (variant, metadata) = variantOverride.Value; - configure.ConfigureOptions.Add(variant.Configuration); - metadataCollection?.Features.Add(new() - { - FeatureName = feature.Name, - FeatureProvider = feature.ProviderName, - VariantId = variant.Id, - IsOverridden = true, - OverrideProviderName = metadata.OverrideProviderName, - }); - } - else - { - double allocationSpot = CalculateAllocationSpot(context, feature.AllocationUnit, feature.Salt, feature.AllocationHash); - Variant? matchingVariant = TryFindMatchingVariant(filteringReceiver, context, feature, allocationSpot); - - if (matchingVariant != null) - { - configure.ConfigureOptions.Add(matchingVariant.Configuration); - metadataCollection?.Features.Add(new() - { - FeatureName = feature.Name, - FeatureProvider = feature.ProviderName, - VariantId = matchingVariant.Id, - }); - } - } - } - } - - if (metadataCollection?.Features.Count > 0) - { - configure.ConfigureOptions.Add(ConfigureFeatureMetadata.Get(metadataCollection, optionsMetadataPropertyName!)); - } - - return configure; - } - - private double CalculateAllocationSpot(TContext context, string? allocationUnit, string salt, IAllocationHash allocationHash) - where TContext : IOptionsContext - { - allocationUnit ??= _options.CurrentValue.DefaultAllocationUnit; - using var allocationReceiver = AllocationContextReceiver.Get(allocationUnit, salt); - context.PopulateReceiver(allocationReceiver); - var allocationSpot = allocationReceiver.GetIdentifierAllocationSpot(allocationHash); - return allocationSpot; - } - - private static string? TryGetMetadataPropertyName() - { - foreach (var property in typeof(TOptions).GetProperties()) - { - if (property.PropertyType == typeof(FeatureMetadata)) - { - return property.Name; - } - } - - return null; - } - - private Variant? TryFindMatchingVariant(FilteringContextReceiver filteringReceiver, TContext context, Feature feature, double allocationSpot) - where TContext : IOptionsContext - { - var variants = new List(feature.Variants); - variants.Sort(FilterCountComparer.Instance); // the one with the most filters first - variants.Sort(PriorityComparer.Instance); // the one with lowest priority first (if specified) - - foreach (var variant in variants) - { - if (!filteringReceiver.Satisfies(variant.Filters)) - { - continue; - } - - var localAllocationSpot = allocationSpot; - if (variant.AllocationUnit != null) - { - localAllocationSpot = CalculateAllocationSpot(context, variant.AllocationUnit, variant.AllocationSalt ?? feature.Salt, variant.AllocationHash); - } - - if (variant.Allocation.Contains(localAllocationSpot)) - { - return variant; - } - } - - return null; - } - - private async Task<(Variant variant, VariantOverride metadata)?> TryGetVariantOverrideAsync(Feature feature, TContext optionsContext, CancellationToken cancellationToken) + private async ValueTask> GetConfigurationForFeaturesAsync(TContext context, CancellationToken cancellationToken) where TContext : IOptionsContext { - foreach (var @override in _variantOverrides) - { - var variantOverride = await @override.TryOverrideAsync(feature, optionsContext, cancellationToken).ConfigureAwait(false); - if (variantOverride != null && feature.Variants.TryGetValue(variantOverride.Id, out var selectedVariant)) - { - return (selectedVariant, variantOverride); - } - } - - return null; - } + var configure = new ConfigureContextualOptions(_configurationSection); - /// - /// Comparer for priority values where nulls are always greater than values so in ascending order will be considered last - /// - private class PriorityComparer : IComparer - { - public static PriorityComparer Instance { get; } = new PriorityComparer(); - public int Compare(Variant? x, Variant? y) + await foreach (var variant in _featureEvaluation.EvaluateFeaturesAsync(context, cancellationToken).ConfigureAwait(false)) { - if (x?.Priority == y?.Priority) return 0; - if (x?.Priority == null) return 1; - if (y?.Priority == null) return -1; - return x.Priority.Value.CompareTo(y.Priority.Value); + configure.ConfigureOptions.Add(variant.Configuration); } - } - /// - /// Compares filter counts, more first - /// - private class FilterCountComparer : IComparer - { - public static FilterCountComparer Instance { get; } = new FilterCountComparer(); - public int Compare(Variant? x, Variant? y) - { - if (x == null && y == null) return 0; - if (x == null) return -1; - if (y == null) return 1; - return (-1) * x.Filters.Count.CompareTo(y.Filters.Count); - } + return configure; } } diff --git a/src/Excos.Options/Contextual/ServiceCollectionExtensions.cs b/src/Excos.Options/Contextual/ServiceCollectionExtensions.cs index e7e6e23..9081c60 100644 --- a/src/Excos.Options/Contextual/ServiceCollectionExtensions.cs +++ b/src/Excos.Options/Contextual/ServiceCollectionExtensions.cs @@ -1,10 +1,8 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 -using Excos.Options.Abstractions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Microsoft.Extensions.Options.Contextual; using Microsoft.Extensions.Options.Contextual.Provider; @@ -33,7 +31,7 @@ public static IServiceCollection ConfigureExcos(this IServiceCollectio services.AddOptions(name).BindConfiguration(section); } - services.AddOptions(); + services.AddExcosFeatureEvaluation(); return services .AddContextualOptions() @@ -41,8 +39,6 @@ public static IServiceCollection ConfigureExcos(this IServiceCollectio new LoadContextualOptions( name, section, - sp.GetServices(), - sp.GetServices(), - sp.GetRequiredService>())); + sp.GetRequiredService())); } } diff --git a/src/Excos.Options/Excos.Options.csproj b/src/Excos.Options/Excos.Options.csproj index be2313c..9ae696e 100644 --- a/src/Excos.Options/Excos.Options.csproj +++ b/src/Excos.Options/Excos.Options.csproj @@ -15,11 +15,11 @@ Excos.Options - 1.0.0-alpha2 + 1.0.0-beta1 Marian Dziubiak and Contributors Excos.Options provides a feature/experiment management system on top of Options.Contextual Apache-2.0 - https://github.com/manio143/excos + https://github.com/excos-platform/config-client README.md @@ -28,11 +28,11 @@ - + - + diff --git a/src/Excos.Options/ExcosOptions.cs b/src/Excos.Options/ExcosOptions.cs deleted file mode 100644 index 598c5ae..0000000 --- a/src/Excos.Options/ExcosOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -namespace Excos.Options; - -/// -/// Options for the Excos feature management. -/// -public sealed class ExcosOptions -{ - /// - /// The default allocation unit used for features if there is no override in the feature configuration. - /// - /// - /// By default it's set to UserId. - /// - public string DefaultAllocationUnit { get; set; } = "UserId"; -} diff --git a/src/Excos.Options/FeatureEvaluation.cs b/src/Excos.Options/FeatureEvaluation.cs new file mode 100644 index 0000000..a5974e0 --- /dev/null +++ b/src/Excos.Options/FeatureEvaluation.cs @@ -0,0 +1,124 @@ +// Copyright (c) Marian Dziubiak and Contributors. +// Licensed under the Apache License, Version 2.0 + +using Excos.Options.Abstractions.Data; +using Excos.Options.Abstractions; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Options.Contextual; +using Microsoft.Extensions.DependencyInjection; + +namespace Excos.Options; + +/// +/// Service collection extension method to register the feature evaluation service. +/// +public static class FeatureEvaluationExtensions +{ + /// + /// Registers the feature evaluation service. + /// + /// Service collection. + /// Input service collection for chaining. + public static IServiceCollection AddExcosFeatureEvaluation(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } + + + + /// + /// Evaluates features for a given context and returns the constructed options object. + /// + /// Options type. + /// Context type. + /// Feature evaluation strategy. + /// The configuration section name corresponding to the path in config under which the options object should be resolved. + /// Context. + /// Cancellation token. + /// An object configured using the evaluated features. + public static async ValueTask EvaluateFeaturesAsync(this IFeatureEvaluation featureEvaluation, string sectionName, TContext context, CancellationToken cancellationToken) + where TOptions : class, new() + where TContext : IOptionsContext + { + var options = new TOptions(); + await foreach (var variant in featureEvaluation.EvaluateFeaturesAsync(context, cancellationToken).ConfigureAwait(false)) + { + variant.Configuration.Configure(options, sectionName); + } + + return options; + } +} + +internal class FeatureEvaluation : IFeatureEvaluation +{ + private readonly IEnumerable _featureProviders; + + public FeatureEvaluation(IEnumerable featureProviders) + { + _featureProviders = featureProviders; + } + + public async IAsyncEnumerable EvaluateFeaturesAsync(TContext context, [EnumeratorCancellation] CancellationToken cancellationToken) + where TContext : IOptionsContext + { + foreach (var provider in _featureProviders) + { + var features = await provider.GetFeaturesAsync(cancellationToken).ConfigureAwait(false); + + foreach (var feature in features) + { + Variant? matchingVariant = TryFindMatchingVariant(context, feature); + if (matchingVariant != null) + { + yield return matchingVariant; + } + } + } + } + + private static Variant? TryFindMatchingVariant(TContext context, Feature feature) + where TContext : IOptionsContext + { + var variants = new List(feature); + variants.Sort(PriorityComparer.Instance); // the one with lowest priority first (if specified) + + foreach (var variant in variants) + { + bool satisfied = true; + foreach (var filter in variant.Filters) + { + if (!filter.IsSatisfiedBy(context)) + { + satisfied = false; + break; + } + } + + if (!satisfied) + { + continue; + } + + return variant; + } + + return null; + } + + /// + /// Comparer for priority values where nulls are always greater than values so in ascending order will be considered last + /// + private class PriorityComparer : IComparer + { + public static PriorityComparer Instance { get; } = new PriorityComparer(); + public int Compare(Variant? x, Variant? y) + { + if (x?.Priority == y?.Priority) return 0; + if (x?.Priority == null) return 1; + if (y?.Priority == null) return -1; + return x.Priority.CompareTo(y.Priority); + } + } +} diff --git a/src/Excos.Options/FeatureMetadata.cs b/src/Excos.Options/FeatureMetadata.cs deleted file mode 100644 index 2f07c24..0000000 --- a/src/Excos.Options/FeatureMetadata.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Marian Dziubiak and Contributors. -// Licensed under the Apache License, Version 2.0 - -using Excos.Options.Abstractions; - -namespace Excos.Options; - -/// -/// Metadata about the features applicable to the current context. -/// -public class FeatureMetadata -{ - /// - /// Collection of per feature metadata. - /// - public ICollection Features { get; init; } = new List(capacity: 8); -} - -/// -/// Feature metadata. -/// -public class FeatureMetadataItem -{ - /// - /// Name of the feature. - /// - public required string FeatureName { get; set; } - - /// - /// Name of the provider which provided the feature. - /// - public required string FeatureProvider { get; set; } - - /// - /// Id of the feature variant applied to the configuration. - /// - public required string VariantId { get; set; } - - /// - /// Whether the variant has been overridden by an provider. - /// - public bool IsOverridden { get; set; } - - /// - /// Name of the provider which provided the override (if is true). - /// - public string? OverrideProviderName { get; set; } -} diff --git a/src/Excos.Options/Filtering/AllocationFilteringCondition.cs b/src/Excos.Options/Filtering/AllocationFilteringCondition.cs new file mode 100644 index 0000000..7a8c0ac --- /dev/null +++ b/src/Excos.Options/Filtering/AllocationFilteringCondition.cs @@ -0,0 +1,28 @@ +// Copyright (c) Marian Dziubiak and Contributors. +// Licensed under the Apache License, Version 2.0 + +using Excos.Options.Abstractions; +using Excos.Options.Abstractions.Data; + +namespace Excos.Options.Filtering; + +internal class AllocationFilteringCondition : PropertyFilteringCondition +{ + private readonly string _salt; + private readonly IAllocationHash _allocationHash; + private readonly Allocation _allocation; + + public AllocationFilteringCondition(string allocationUnit, string salt, IAllocationHash allocationHash, Allocation allocation) : base(allocationUnit) + { + _salt = salt; + _allocationHash = allocationHash; + _allocation = allocation; + } + + protected override bool PropertyPredicate(T value) + { + var input = value?.ToString() ?? string.Empty; + var spot = _allocationHash.GetAllocationSpot(_salt, input); + return _allocation.Contains(spot); + } +} diff --git a/src/Excos.Options/Filtering/NeverFilteringCondition.cs b/src/Excos.Options/Filtering/NeverFilteringCondition.cs index 314de81..0e26f0c 100644 --- a/src/Excos.Options/Filtering/NeverFilteringCondition.cs +++ b/src/Excos.Options/Filtering/NeverFilteringCondition.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0 using Excos.Options.Abstractions; +using Microsoft.Extensions.Options.Contextual; namespace Excos.Options.Filtering; @@ -18,5 +19,5 @@ internal NeverFilteringCondition() { } public static NeverFilteringCondition Instance { get; } = new(); /// - public bool IsSatisfiedBy(T value) => false; + public bool IsSatisfiedBy(T value) where T : IOptionsContext => false; } diff --git a/src/Excos.Options/Filtering/OrFilteringCondition.cs b/src/Excos.Options/Filtering/OrFilteringCondition.cs new file mode 100644 index 0000000..a6c6489 --- /dev/null +++ b/src/Excos.Options/Filtering/OrFilteringCondition.cs @@ -0,0 +1,27 @@ +// Copyright (c) Marian Dziubiak and Contributors. +// Licensed under the Apache License, Version 2.0 + +using Excos.Options.Abstractions; +using Microsoft.Extensions.Options.Contextual; + +namespace Excos.Options.Filtering; + +internal class OrFilteringCondition : IFilteringCondition +{ + private readonly IFilteringCondition[] _conditions; + public OrFilteringCondition(params IFilteringCondition[] conditions) + { + _conditions = conditions; + } + public bool IsSatisfiedBy(T value) where T : IOptionsContext + { + foreach (var condition in _conditions) + { + if (condition.IsSatisfiedBy(value)) + { + return true; + } + } + return false; + } +} diff --git a/src/Excos.Options/Filtering/PropertyFilteringCondition.cs b/src/Excos.Options/Filtering/PropertyFilteringCondition.cs new file mode 100644 index 0000000..5abe8d1 --- /dev/null +++ b/src/Excos.Options/Filtering/PropertyFilteringCondition.cs @@ -0,0 +1,50 @@ +// Copyright (c) Marian Dziubiak and Contributors. +// Licensed under the Apache License, Version 2.0 + +using Excos.Options.Abstractions; +using Microsoft.Extensions.Options.Contextual; +using Microsoft.Extensions.Options.Contextual.Provider; + +namespace Excos.Options.Filtering; + +internal abstract class PropertyFilteringCondition : IFilteringCondition +{ + private readonly string _propertyName; + + public PropertyFilteringCondition(string propertyName) + { + _propertyName = propertyName; + } + + public bool IsSatisfiedBy(TContext value) where TContext : IOptionsContext + { + var receiver = new PropertyValueReceiver(_propertyName, this); + value.PopulateReceiver(receiver); + return receiver.IsSatisfied; + } + + protected abstract bool PropertyPredicate(T value); + + private class PropertyValueReceiver : IOptionsContextReceiver + { + private readonly string _propertyName; + private readonly PropertyFilteringCondition _condition; + + // defaults to false - if no property we expect is received + public bool IsSatisfied { get; private set; } + + public PropertyValueReceiver(string propertyName, PropertyFilteringCondition condition) + { + _propertyName = propertyName; + _condition = condition; + } + + public void Receive(string key, T value) + { + if (key != null && key.Equals(_propertyName, StringComparison.OrdinalIgnoreCase)) + { + IsSatisfied = _condition.PropertyPredicate(value); + } + } + } +} diff --git a/src/Excos.Options/Filtering/RangeFilteringCondition.cs b/src/Excos.Options/Filtering/RangeFilteringCondition.cs index 16a02a8..8734366 100644 --- a/src/Excos.Options/Filtering/RangeFilteringCondition.cs +++ b/src/Excos.Options/Filtering/RangeFilteringCondition.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0 using System.Runtime.CompilerServices; -using Excos.Options.Abstractions; using Excos.Options.Abstractions.Data; namespace Excos.Options.Filtering; @@ -11,7 +10,7 @@ namespace Excos.Options.Filtering; /// A filtering condition that checks if a value is within a specified range. /// /// Type of the range. -public class RangeFilteringCondition : IFilteringCondition +internal class RangeFilteringCondition : PropertyFilteringCondition where F : IComparable, ISpanParsable { private readonly Range _range; @@ -19,14 +18,15 @@ public class RangeFilteringCondition : IFilteringCondition /// /// Creates a new filtering condition that checks if a value is within a specified range. /// + /// Property name. /// Range to check. - public RangeFilteringCondition(Range range) + public RangeFilteringCondition(string propertyName, Range range) : base(propertyName) { _range = range; } /// - public bool IsSatisfiedBy(T value) + protected override bool PropertyPredicate(T value) { if (typeof(T) == typeof(F)) { diff --git a/src/Excos.Options/Filtering/RegexFilteringCondition.cs b/src/Excos.Options/Filtering/RegexFilteringCondition.cs index b4b37e6..bb82304 100644 --- a/src/Excos.Options/Filtering/RegexFilteringCondition.cs +++ b/src/Excos.Options/Filtering/RegexFilteringCondition.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0 using System.Text.RegularExpressions; -using Excos.Options.Abstractions; namespace Excos.Options.Filtering; @@ -12,21 +11,22 @@ namespace Excos.Options.Filtering; /// /// Matching is case-insensitive and culture-invariant. /// -public class RegexFilteringCondition : IFilteringCondition +internal class RegexFilteringCondition : PropertyFilteringCondition { private readonly Regex _regex; /// /// Creates a new instance of the filter using the Regex . /// + /// Property name. /// Regular expression acceptable by . - public RegexFilteringCondition(string expression) + public RegexFilteringCondition(string propertyName, string expression) : base(propertyName) { _regex = new Regex(expression, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } /// - public bool IsSatisfiedBy(T value) + protected override bool PropertyPredicate(T value) { return _regex.IsMatch(value?.ToString() ?? string.Empty); } diff --git a/src/Excos.Options/Filtering/StringFilteringCondition.cs b/src/Excos.Options/Filtering/StringFilteringCondition.cs index 1fdc494..74e7b37 100644 --- a/src/Excos.Options/Filtering/StringFilteringCondition.cs +++ b/src/Excos.Options/Filtering/StringFilteringCondition.cs @@ -1,8 +1,6 @@ // Copyright (c) Marian Dziubiak and Contributors. // Licensed under the Apache License, Version 2.0 -using Excos.Options.Abstractions; - namespace Excos.Options.Filtering; /// @@ -11,21 +9,22 @@ namespace Excos.Options.Filtering; /// /// Matching is case-insensitive and culture-invariant. /// -public class StringFilteringCondition : IFilteringCondition +internal class StringFilteringCondition : PropertyFilteringCondition { private readonly string _source; /// /// Creates a new instance of the filter using the string. /// + /// Property name. /// String to match. - public StringFilteringCondition(string source) + public StringFilteringCondition(string propertyName, string source) : base(propertyName) { _source = source; } /// - public bool IsSatisfiedBy(T value) + protected override bool PropertyPredicate(T value) { return string.Equals(value?.ToString(), _source, StringComparison.InvariantCultureIgnoreCase); } diff --git a/src/Excos.Options/IFeatureEvaluation.cs b/src/Excos.Options/IFeatureEvaluation.cs new file mode 100644 index 0000000..8baead9 --- /dev/null +++ b/src/Excos.Options/IFeatureEvaluation.cs @@ -0,0 +1,24 @@ +// Copyright (c) Marian Dziubiak and Contributors. +// Licensed under the Apache License, Version 2.0 + +using Excos.Options.Abstractions.Data; +using Microsoft.Extensions.Options.Contextual; + +namespace Excos.Options +{ + /// + /// The main interface for feature evaluation of Excos. + /// + public interface IFeatureEvaluation + { + /// + /// Evaluates features for a given context. + /// + /// Context type. + /// Context. + /// Cancellation token. + /// Selected variants. + IAsyncEnumerable EvaluateFeaturesAsync(TContext context, CancellationToken cancellationToken) + where TContext : IOptionsContext; + } +} diff --git a/src/Excos.Options/Providers/Configuration/FeatureConfigurationExtensions.cs b/src/Excos.Options/Providers/Configuration/FeatureConfigurationExtensions.cs index fcc48a7..ba35dcb 100644 --- a/src/Excos.Options/Providers/Configuration/FeatureConfigurationExtensions.cs +++ b/src/Excos.Options/Providers/Configuration/FeatureConfigurationExtensions.cs @@ -17,6 +17,8 @@ namespace Excos.Options.Providers.Configuration; /// public static class FeatureConfigurationExtensions { + const string ProviderName = "Configuration"; + /// /// Configures Excos features from using . /// @@ -28,47 +30,55 @@ public static void ConfigureExcosFeatures(this IServiceCollection services, stri services.TryAddEnumerable(new ServiceDescriptor(typeof(IFeatureFilterParser), typeof(RangeFilterParser), ServiceLifetime.Singleton)); services.TryAddEnumerable(new ServiceDescriptor(typeof(IFeatureProvider), typeof(OptionsFeatureProvider), ServiceLifetime.Singleton)); - services.AddOptions() + services.AddOptions>() .Configure, IConfiguration>((features, filterParsers, configuration) => { filterParsers = filterParsers.Reverse().ToList(); // filters added last should be tried first configuration = configuration.GetSection(sectionName); foreach (var section in configuration.GetChildren()) { - const string providerName = "Configuration"; var featureName = section.Key; - if (features.Contains(featureName)) + if (features.Any(f => f.Name.Equals(featureName, StringComparison.OrdinalIgnoreCase))) { // skip adding the same feature more than once in case someone calls this method more than once continue; } var enabled = section.GetValue("Enabled") ?? true; + if (!enabled) + { + continue; + } + var salt = section.GetValue("Salt"); - var filters = LoadFilters(filterParsers, section.GetSection("Filters")); - var variants = LoadVariants(filterParsers, section.GetSection("Variants")); + var filters = LoadFilters(filterParsers, section.GetSection("Filters")).ToList(); + var variants = LoadVariants(filterParsers, featureName, salt ?? $"{ProviderName}_{featureName}", section.GetSection("Variants")); var feature = new Feature { Name = featureName, - ProviderName = providerName, - Enabled = enabled, - Salt = salt!, // internally salt can be null }; - feature.Filters.AddRange(filters); - feature.Variants.AddRange(variants); + + feature.AddRange(variants); + + foreach (var variant in feature) + { + variant.Filters = variant.Filters.Concat(filters); + } + features.Add(feature); } }); } - private static IEnumerable LoadVariants(IEnumerable filterParsers, IConfiguration variantsConfiguration) + private static IEnumerable LoadVariants(IEnumerable filterParsers, string featureName, string salt, IConfiguration variantsConfiguration) { foreach (var section in variantsConfiguration.GetChildren()) { var variantId = section.Key; + var allocationUnit = section.GetValue("AllocationUnit")?.Trim() ?? "UserId"; var allocationString = section.GetValue("Allocation")?.Trim(); if (!Range.TryParse(allocationString, null, out var range)) { @@ -91,73 +101,63 @@ private static IEnumerable LoadVariants(IEnumerable("Priority"); - var filters = LoadFilters(filterParsers, section.GetSection("Filters")); + var filters = LoadFilters(filterParsers, section.GetSection("Filters")).ToList(); var configuration = new ConfigurationBasedConfigureOptions(section.GetSection("Settings")); var variant = new Variant { - Id = variantId, - Allocation = allocation, + Id = $"{featureName}:{variantId}", Configuration = configuration, - Priority = priority, + // if priority is not specified, we give priority to variants with more filters + Priority = priority ?? 1024 - filters.Count, }; - variant.Filters.AddRange(filters); + variant.Filters = [ + new AllocationFilteringCondition(allocationUnit, salt, XxHashAllocation.Instance, allocation), + ..filters + ]; yield return variant; } } - private static IEnumerable LoadFilters(IEnumerable filterParsers, IConfiguration filtersConfiguration) + private static IEnumerable LoadFilters(IEnumerable filterParsers, IConfiguration filtersConfiguration) { foreach (var section in filtersConfiguration.GetChildren()) { var propertyName = section.Key; - IEnumerable? filteringConditions = null; + IFilteringCondition? filteringCondition = null; var children = section.GetChildren(); if (children.FirstOrDefault() is IConfigurationSection child && child.Key == "0") { // this is an array, thus we must parse values within to get OR treatment - filteringConditions = children.Select(c => ParseFilter(filterParsers, c)) - .Where(f => f != null).Cast(); + filteringCondition = new OrFilteringCondition(children.Select(c => ParseFilter(filterParsers, propertyName, c)) + .Where(f => f != null).Cast().ToArray()); } - if (filteringConditions == null) + if (filteringCondition == null) { // this is a single value, let's try to parse it - var filteringCondition = ParseFilter(filterParsers, section); - if (filteringCondition != null) - { - filteringConditions = new[] { filteringCondition }; - } + filteringCondition = ParseFilter(filterParsers, propertyName, section); } // if no condition was parsed we will prevent running this filter - if (filteringConditions != null) + if (filteringCondition != null) { - var filter = new Filter - { - PropertyName = propertyName, - }; - filter.Conditions.AddRange(filteringConditions); - yield return filter; + yield return filteringCondition; } else { - yield return new Filter - { - PropertyName = propertyName, - Conditions = { NeverFilteringCondition.Instance } - }; + yield return NeverFilteringCondition.Instance; } } } - private static IFilteringCondition? ParseFilter(IEnumerable filterParsers, IConfiguration configuration) + private static IFilteringCondition? ParseFilter(IEnumerable filterParsers, string propertyName, IConfiguration configuration) { foreach (var parser in filterParsers) { - if (parser.TryParseFilter(configuration, out var filteringCondition)) + if (parser.TryParseFilter(propertyName, configuration, out var filteringCondition)) { return filteringCondition; } diff --git a/src/Excos.Options/Providers/Configuration/FilterParsers/RangeFilterParser.cs b/src/Excos.Options/Providers/Configuration/FilterParsers/RangeFilterParser.cs index d029a59..71d829f 100644 --- a/src/Excos.Options/Providers/Configuration/FilterParsers/RangeFilterParser.cs +++ b/src/Excos.Options/Providers/Configuration/FilterParsers/RangeFilterParser.cs @@ -11,7 +11,7 @@ namespace Excos.Options.Providers.Configuration.FilterParsers; internal class RangeFilterParser : IFeatureFilterParser { - public bool TryParseFilter(IConfiguration configuration, [NotNullWhen(true)] out IFilteringCondition? filteringCondition) + public bool TryParseFilter(string propertyName, IConfiguration configuration, [NotNullWhen(true)] out IFilteringCondition? filteringCondition) { var pattern = configuration.Get(); if (pattern == null) @@ -22,17 +22,17 @@ public bool TryParseFilter(IConfiguration configuration, [NotNullWhen(true)] out if (Range.TryParse(pattern, null, out var guidRange)) { - filteringCondition = new RangeFilteringCondition(guidRange); + filteringCondition = new RangeFilteringCondition(propertyName, guidRange); return true; } if (Range.TryParse(pattern, null, out var dateRange)) { - filteringCondition = new RangeFilteringCondition(dateRange); + filteringCondition = new RangeFilteringCondition(propertyName, dateRange); return true; } if (Range.TryParse(pattern, null, out var doubleRange)) { - filteringCondition = new RangeFilteringCondition(doubleRange); + filteringCondition = new RangeFilteringCondition(propertyName, doubleRange); return true; } diff --git a/src/Excos.Options/Providers/Configuration/FilterParsers/StringFilterParser.cs b/src/Excos.Options/Providers/Configuration/FilterParsers/StringFilterParser.cs index aae5ad6..843029d 100644 --- a/src/Excos.Options/Providers/Configuration/FilterParsers/StringFilterParser.cs +++ b/src/Excos.Options/Providers/Configuration/FilterParsers/StringFilterParser.cs @@ -11,7 +11,7 @@ namespace Excos.Options.Providers.Configuration.FilterParsers; internal class StringFilterParser : IFeatureFilterParser { - public bool TryParseFilter(IConfiguration configuration, [NotNullWhen(true)] out IFilteringCondition? filteringCondition) + public bool TryParseFilter(string propertyName, IConfiguration configuration, [NotNullWhen(true)] out IFilteringCondition? filteringCondition) { var pattern = configuration.Get(); if (pattern == null) @@ -22,17 +22,17 @@ public bool TryParseFilter(IConfiguration configuration, [NotNullWhen(true)] out if (pattern.StartsWith('^')) { - filteringCondition = new RegexFilteringCondition(pattern); + filteringCondition = new RegexFilteringCondition(propertyName, pattern); return true; } else if (pattern.Contains('*')) { - filteringCondition = new RegexFilteringCondition(Regex.Escape(pattern).Replace("\\*", ".*")); + filteringCondition = new RegexFilteringCondition(propertyName, Regex.Escape(pattern).Replace("\\*", ".*")); return true; } else { - filteringCondition = new StringFilteringCondition(pattern); + filteringCondition = new StringFilteringCondition(propertyName, pattern); return true; } } diff --git a/src/Excos.Options/Providers/Configuration/IFeatureFilterParser.cs b/src/Excos.Options/Providers/Configuration/IFeatureFilterParser.cs index 6fbb0f9..ff6812f 100644 --- a/src/Excos.Options/Providers/Configuration/IFeatureFilterParser.cs +++ b/src/Excos.Options/Providers/Configuration/IFeatureFilterParser.cs @@ -16,8 +16,9 @@ public interface IFeatureFilterParser /// Tries to process the section and create a filtering condition. /// The provided section either has a string value or represents a nested object. ///
+ /// Property name. /// Configuration section. /// A parsed filtering condition. /// True if parsing was successful, false otherwise. - bool TryParseFilter(IConfiguration configuration, [NotNullWhen(true)] out IFilteringCondition? filteringCondition); + bool TryParseFilter(string propertyName, IConfiguration configuration, [NotNullWhen(true)] out IFilteringCondition? filteringCondition); } diff --git a/src/Excos.Options/Providers/OptionsFeatureBuilder.cs b/src/Excos.Options/Providers/OptionsFeatureBuilder.cs index ea21ac8..820636a 100644 --- a/src/Excos.Options/Providers/OptionsFeatureBuilder.cs +++ b/src/Excos.Options/Providers/OptionsFeatureBuilder.cs @@ -6,6 +6,7 @@ using Excos.Options.Abstractions; using Excos.Options.Abstractions.Data; using Excos.Options.Filtering; +using Excos.Options.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -17,17 +18,19 @@ namespace Excos.Options.Providers; ///
public sealed class OptionsFeatureBuilder { - private readonly OptionsBuilder _optionsBuilder; + private readonly OptionsBuilder> _optionsBuilder; internal Feature Feature { get; } + internal string ProviderName { get; } + internal List Filters { get; } = []; - internal OptionsFeatureBuilder(OptionsBuilder optionsBuilder, string featureName, string providerName) + internal OptionsFeatureBuilder(OptionsBuilder> optionsBuilder, string featureName, string providerName) { _optionsBuilder = optionsBuilder; + ProviderName = providerName; Feature = new Feature { Name = featureName, - ProviderName = providerName, }; } @@ -35,8 +38,14 @@ internal OptionsFeatureBuilder(OptionsBuilder optionsBuilder, /// Saves the feature to the collection. /// /// Options builder for further method chaining. - public OptionsBuilder Save() => - _optionsBuilder.Configure(features => features.Add(Feature)); + public OptionsBuilder> Save() + { + foreach (var variant in Feature) + { + variant.Filters = variant.Filters.Concat(Filters).ToList(); + } + return _optionsBuilder.Configure(features => features.Add(Feature)); + } } /// @@ -44,17 +53,14 @@ public OptionsBuilder Save() => /// public sealed class OptionsFeatureFilterBuilder { + internal string PropertyName { get; } internal OptionsFeatureBuilder FeatureBuilder { get; } - - internal Filter Filter { get; } + internal List Filter { get; } = []; internal OptionsFeatureFilterBuilder(OptionsFeatureBuilder featureBuilder, string propertyName) { FeatureBuilder = featureBuilder; - Filter = new Filter - { - PropertyName = propertyName, - }; + PropertyName = propertyName; } /// @@ -63,7 +69,12 @@ internal OptionsFeatureFilterBuilder(OptionsFeatureBuilder featureBuilder, strin /// Feature builder for further method chaining. public OptionsFeatureBuilder SaveFilter() { - FeatureBuilder.Feature.Filters.Add(Filter); + FeatureBuilder.Filters.Add(Filter.Count == 0 + ? NeverFilteringCondition.Instance + : Filter.Count == 1 + ? Filter[0] + : new OrFilteringCondition(Filter.ToArray())); + return FeatureBuilder; } } @@ -104,7 +115,7 @@ public static OptionsFeatureBuilder BuildFeature(this IServiceCollection service { services.AddExcosOptionsFeatureProvider(); return new OptionsFeatureBuilder( - services.AddOptions(), + services.AddOptions>(), featureName, providerName); } @@ -115,7 +126,7 @@ public static OptionsFeatureBuilder BuildFeature(this IServiceCollection service /// Options builder. /// Feature name. /// Builder. - public static OptionsFeatureBuilder BuildFeature(this OptionsBuilder optionsBuilder, string featureName) => + public static OptionsFeatureBuilder BuildFeature(this OptionsBuilder> optionsBuilder, string featureName) => optionsBuilder.BuildFeature(featureName, Assembly.GetCallingAssembly().GetName()?.Name ?? nameof(OptionsFeatureBuilder)); /// @@ -125,7 +136,7 @@ public static OptionsFeatureBuilder BuildFeature(this OptionsBuilderFeature name. /// Provider name. /// Builder. - public static OptionsFeatureBuilder BuildFeature(this OptionsBuilder optionsBuilder, string featureName, string providerName) + public static OptionsFeatureBuilder BuildFeature(this OptionsBuilder> optionsBuilder, string featureName, string providerName) { optionsBuilder.Services.AddExcosOptionsFeatureProvider(); return new OptionsFeatureBuilder(optionsBuilder, featureName, providerName); @@ -167,7 +178,7 @@ public static OptionsFeatureFilterBuilder WithFilter(this OptionsFeatureBuilder /// Builder. public static OptionsFeatureFilterBuilder Matches(this OptionsFeatureFilterBuilder builder, string value) { - builder.Filter.Conditions.Add(new StringFilteringCondition(value)); + builder.Filter.Add(new StringFilteringCondition(builder.PropertyName, value)); return builder; } @@ -179,7 +190,7 @@ public static OptionsFeatureFilterBuilder Matches(this OptionsFeatureFilterBuild /// Builder. public static OptionsFeatureFilterBuilder RegexMatches(this OptionsFeatureFilterBuilder builder, string pattern) { - builder.Filter.Conditions.Add(new RegexFilteringCondition(pattern)); + builder.Filter.Add(new RegexFilteringCondition(builder.PropertyName, pattern)); return builder; } @@ -192,7 +203,7 @@ public static OptionsFeatureFilterBuilder RegexMatches(this OptionsFeatureFilter public static OptionsFeatureFilterBuilder InRange(this OptionsFeatureFilterBuilder builder, Range range) where T : IComparable, ISpanParsable { - builder.Filter.Conditions.Add(new RangeFilteringCondition(range)); + builder.Filter.Add(new RangeFilteringCondition(builder.PropertyName, range)); return builder; } @@ -203,19 +214,32 @@ public static OptionsFeatureFilterBuilder InRange(this OptionsFeatureFilterBu /// Builder. /// Configuration callback taking the options object and section name (variant A). /// Configuration callback taking the options object and section name (variant B). + /// Property of the context used for allocation. /// Builder. - public static OptionsFeatureBuilder ABExperiment(this OptionsFeatureBuilder optionsFeatureBuilder, Action configureA, Action configureB) + public static OptionsFeatureBuilder ABExperiment(this OptionsFeatureBuilder optionsFeatureBuilder, Action configureA, Action configureB, string allocationUnit = "UserId") { - optionsFeatureBuilder.Feature.Variants.Add(new Variant + optionsFeatureBuilder.Feature.Add(new Variant { - Id = "A", - Allocation = new Allocation(new Range(0, 0.5, RangeType.IncludeStart)), + Id = $"{optionsFeatureBuilder.Feature.Name}:A_{optionsFeatureBuilder.Feature.Count}", + Filters = [ + new AllocationFilteringCondition( + allocationUnit, + $"{optionsFeatureBuilder.ProviderName}_{optionsFeatureBuilder.Feature.Name}", + XxHashAllocation.Instance, + new Allocation(new Range(0, 0.5, RangeType.IncludeStart))) + ], Configuration = new CallbackConfigureOptions(configureA), }); - optionsFeatureBuilder.Feature.Variants.Add(new Variant + optionsFeatureBuilder.Feature.Add(new Variant { - Id = "B", - Allocation = new Allocation(new Range(0.5, 1, RangeType.IncludeBoth)), + Id = $"{optionsFeatureBuilder.Feature.Name}:B_{optionsFeatureBuilder.Feature.Count}", + Filters = [ + new AllocationFilteringCondition( + allocationUnit, + $"{optionsFeatureBuilder.ProviderName}_{optionsFeatureBuilder.Feature.Name}", + XxHashAllocation.Instance, + new Allocation(new Range(0.5, 1, RangeType.IncludeBoth))) + ], Configuration = new CallbackConfigureOptions(configureB), }); @@ -229,13 +253,20 @@ public static OptionsFeatureBuilder ABExperiment(this OptionsFeatureBu /// Builder. /// Rollout percentage (0-100%) /// Configuration callback taking the options object and section name. + /// Property of the context used for allocation. /// Builder. - public static OptionsFeatureBuilder Rollout(this OptionsFeatureBuilder optionsFeatureBuilder, double percentage, Action configure) + public static OptionsFeatureBuilder Rollout(this OptionsFeatureBuilder optionsFeatureBuilder, double percentage, Action configure, string allocationUnit = "UserId") { - optionsFeatureBuilder.Feature.Variants.Add(new Variant + optionsFeatureBuilder.Feature.Add(new Variant { - Id = "Rollout", - Allocation = Allocation.Percentage(percentage), + Id = $"{optionsFeatureBuilder.Feature.Name}:Rollout_{optionsFeatureBuilder.Feature.Count}", + Filters = [ + new AllocationFilteringCondition( + allocationUnit, + $"{optionsFeatureBuilder.ProviderName}_{optionsFeatureBuilder.Feature.Name}", + XxHashAllocation.Instance, + Allocation.Percentage(percentage)) + ], Configuration = new CallbackConfigureOptions(configure), }); diff --git a/src/Excos.Options/Providers/OptionsFeatureProvider.cs b/src/Excos.Options/Providers/OptionsFeatureProvider.cs index 679abe4..a24e7c6 100644 --- a/src/Excos.Options/Providers/OptionsFeatureProvider.cs +++ b/src/Excos.Options/Providers/OptionsFeatureProvider.cs @@ -9,9 +9,9 @@ namespace Excos.Options.Providers; internal class OptionsFeatureProvider : IFeatureProvider { - private readonly IOptionsMonitor _options; + private readonly IOptionsMonitor> _options; - public OptionsFeatureProvider(IOptionsMonitor options) + public OptionsFeatureProvider(IOptionsMonitor> options) { _options = options; } diff --git a/src/Excos.Options/Readme.md b/src/Excos.Options/Readme.md index 3ad4ca7..353f6ba 100644 --- a/src/Excos.Options/Readme.md +++ b/src/Excos.Options/Readme.md @@ -2,4 +2,4 @@ This library aims to provide experiment configuration framework on top of [Microsoft.Extensions.Options.Contextual](https://www.nuget.org/packages/Microsoft.Extensions.Options.Contextual). -See the GitHub repository for more information: [Excos](https://github.com/manio143/excos). +See the GitHub repository for more information: [Excos](https://github.com/excos-platform/config-client). diff --git a/src/Excos.Options/Utils/XxHashAllocation.cs b/src/Excos.Options/Utils/XxHashAllocation.cs index 26da0f7..ba88f14 100644 --- a/src/Excos.Options/Utils/XxHashAllocation.cs +++ b/src/Excos.Options/Utils/XxHashAllocation.cs @@ -13,7 +13,7 @@ internal class XxHashAllocation : IAllocationHash public double GetAllocationSpot(string salt, string identifier) { - var source = $"{salt}_{identifier}"; + var source = $"{salt}_{identifier}"; // TODO: can we perform this concat on the stack? var hash = XxHash32.HashToUInt32(MemoryMarshal.AsBytes(source.AsSpan())); return (double)hash / uint.MaxValue; } diff --git a/src/Excos.SourceGen/PrivatePoolGenerator.cs b/src/Excos.SourceGen/PrivatePoolGenerator.cs index f5f74bd..a824da2 100644 --- a/src/Excos.SourceGen/PrivatePoolGenerator.cs +++ b/src/Excos.SourceGen/PrivatePoolGenerator.cs @@ -70,7 +70,7 @@ private static void HandleAnnotatedTypes(Compilation compilation, IEnumerable GetPrivatePoolTypes(Dictionary> types) =>