From 406637772cff641005cc59ce9c0ba1b5ac5a188b Mon Sep 17 00:00:00 2001 From: Adam Simon Date: Wed, 15 Nov 2023 17:46:48 +0100 Subject: [PATCH] Don't force users to pass user object attributes as strings --- .../ConfigV2EvaluationTests.cs | 174 ++++++++++++++++- .../MatrixTestRunnerBase.cs | 2 + src/ConfigCat.Client.Tests/UserTests.cs | 33 ---- .../Evaluation/EvaluateContext.cs | 4 +- .../Evaluation/RolloutEvaluator.cs | 182 ++++++++++++------ .../Extensions/ObjectExtensions.cs | 41 ++++ src/ConfigCatClient/Logging/LogMessages.cs | 5 + src/ConfigCatClient/User.cs | 94 +++++---- 8 files changed, 395 insertions(+), 140 deletions(-) diff --git a/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs b/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs index aa3f8adf..85965b29 100644 --- a/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs +++ b/src/ConfigCat.Client.Tests/ConfigV2EvaluationTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -212,7 +213,6 @@ public void SegmentMatrixTests(string configLocation, string settingKey, string userId, userEmail, userCountry, userCustomAttributeName, userCustomAttributeValue); } - [DataTestMethod] [DynamicData(nameof(UnicodeMatrixTestsDescriptor.GetTests), typeof(UnicodeMatrixTestsDescriptor), DynamicDataSourceType.Method)] public void UnicodeMatrixTests(string configLocation, string settingKey, string expectedReturnValue, @@ -413,4 +413,176 @@ public void EvaluationDetails_MatchedEvaluationRuleAndPercantageOption_Test(stri Assert.AreEqual(expectedIsExpectedMatchedTargetingRuleSet, evaluationDetails.MatchedTargetingRule is not null); Assert.AreEqual(expectedIsExpectedMatchedPercentageOptionSet, evaluationDetails.MatchedPercentageOption is not null); } + + [TestMethod] + public void UserObjectAttributeValueConversion_TextComparisons_Test() + { + var config = new ConfigLocation.Cdn("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ").FetchConfigCached(); + + var logEvents = new List(); + var logger = LoggingHelper.CreateCapturingLogger(logEvents).AsWrapper(); + var evaluator = new RolloutEvaluator(logger); + + const string customAttributeName = "Custom1"; + const int customAttributeValue = 42; + var user = new User("12345") { Custom = { [customAttributeName] = customAttributeValue } }; + + const string key = "boolTextEqualsNumber"; + var evaluationDetails = evaluator.Evaluate(config!.Settings, key, defaultValue: null, user, remoteConfig: null, logger); + + Assert.AreEqual(true, evaluationDetails.Value); + + var warnings = logEvents.Where(evt => evt.Level == LogLevel.Warning).ToArray(); + Assert.AreEqual(1, warnings.Length); + Assert.AreEqual(3005, warnings[0].EventId); + + var message = warnings[0].Message.ToString(); + var expectedAttributeValueText = ((double)customAttributeValue).ToString(CultureInfo.InvariantCulture); + Assert.AreEqual($"Evaluation of condition (User.{customAttributeName} EQUALS '{expectedAttributeValueText}') for setting '{key}' may not produce the expected result (the User.{customAttributeName} attribute is not a string value, thus it was automatically converted to the string value '{expectedAttributeValueText}'). Please make sure that using a non-string value was intended.", message); + } + + [DataTestMethod] + // SemVer-based comparisons + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "0.0", "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "0.9.9", "< 1.0.0")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "1.0.0", "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "1.1", "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 0, "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 0.9, "20%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 2, "20%")] + // Number-based comparisons + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)-1, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (sbyte)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (byte)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (byte)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (byte)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)-1, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (short)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (ushort)2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (ushort)3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", (ushort)5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", -1, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2u, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3u, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5u, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", long.MinValue, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2L, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3L, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5L, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", long.MaxValue, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2ul, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3ul, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5ul, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", ulong.MaxValue, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float.NegativeInfinity, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", -1f, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2f, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2.1f, "<2.1")] // 2.1f < 2.1d as (double)2.1f is 2.0999999046325684 !!! However, this is how IEEE 754 works, so we don't bother about it. + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3f, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5f, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float.PositiveInfinity, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float.NaN, "80%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", double.NegativeInfinity, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", -1d, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2d, "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2.1d, "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3d, "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5d, ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", double.PositiveInfinity, ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", double.NaN, "80%")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:-79228162514264337593543950335", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:2", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:2.1", "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:3", "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:5", ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "decimal:79228162514264337593543950335", ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "-Infinity", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "-1", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2", "<2.1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2.1", "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2,1", "<=2,1")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "3", "<>4.2")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "5", ">=5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "Infinity", ">5")] + [DataRow("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "NaN", "80%")] + // Date time-based comparisons + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-03-31T23:59:59.9990000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-01T01:59:59.9990000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-01T00:00:00.0010000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-01T02:00:00.0010000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-04-30T23:59:59.9990000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-05-01T01:59:59.9990000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-05-01T00:00:00.0010000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetime:2023-05-01T02:00:00.0010000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-03-31T23:59:59.9990000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-01T01:59:59.9990000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-01T00:00:00.0010000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-01T02:00:00.0010000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-04-30T23:59:59.9990000Z", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-05-01T01:59:59.9990000+02:00", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-05-01T00:00:00.0010000Z", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "datetimeoffset:2023-05-01T02:00:00.0010000+02:00", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", double.NegativeInfinity, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307199.999, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307200.001, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899199.999, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899200.001, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", double.PositiveInfinity, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", double.NaN, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307199, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307201, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899199, true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899201, false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "-Infinity", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1680307199.999", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1680307200.001", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1682899199.999", true)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1682899200.001", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "+Infinity", false)] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "NaN", false)] + // String array-based comparisons + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", new string[] { "x", "read" }, "Dog")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", new string[] { "x", "Read" }, "Cat")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "[\"x\", \"read\"]", "Dog")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "[\"x\", \"Read\"]", "Cat")] + [DataRow("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "x, read", "Cat")] + public void UserObjectAttributeValueConversion_NonTextComparisons_Test(string sdkKey, string key, string? userId, string customAttributeName, object customAttributeValue, + object expectedReturnValue) + { + var config = new ConfigLocation.Cdn(sdkKey).FetchConfigCached(); + + var logger = new Mock().Object.AsWrapper(); + var evaluator = new RolloutEvaluator(logger); + + if (customAttributeValue is string s) + { + const string decimalPrefix = "decimal:", dateTimePrefix = "datetime:", dateTimeOffsetPrefix = "datetimeoffset:"; + if (s.StartsWith(decimalPrefix, StringComparison.Ordinal)) + { + customAttributeValue = decimal.Parse(s.Substring(decimalPrefix.Length)); + } + else if (s.StartsWith(dateTimePrefix, StringComparison.Ordinal)) + { + var dateTimeStyle = s.EndsWith("Z", StringComparison.Ordinal) ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.None; + customAttributeValue = DateTime.ParseExact(s.Substring(dateTimePrefix.Length), "o", CultureInfo.InvariantCulture, dateTimeStyle); + } + else if (s.StartsWith(dateTimeOffsetPrefix, StringComparison.Ordinal)) + { + customAttributeValue = DateTimeOffset.ParseExact(s.Substring(dateTimeOffsetPrefix.Length), "o", CultureInfo.InvariantCulture); + } + } + + var user = userId is not null ? new User(userId) { Custom = { [customAttributeName] = customAttributeValue! } } : null; + + var evaluationDetails = evaluator.Evaluate(config!.Settings, key, defaultValue: null, user, remoteConfig: null, logger); + + Assert.AreEqual(expectedReturnValue, evaluationDetails.Value); + } } diff --git a/src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs b/src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs index a45edb82..798831a9 100644 --- a/src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs +++ b/src/ConfigCat.Client.Tests/MatrixTestRunnerBase.cs @@ -135,7 +135,9 @@ internal bool RunTest(IRolloutEvaluator evaluator, LoggerWrapper logger, string { user = new User(userId) { Email = userEmail, Country = userCountry }; if (userCustomAttributeValue is not null) + { user.Custom[userCustomAttributeName!] = userCustomAttributeValue; + } } return RunTest(evaluator, logger, settingKey, expectedReturnValue, user); diff --git a/src/ConfigCat.Client.Tests/UserTests.cs b/src/ConfigCat.Client.Tests/UserTests.cs index d4495e02..c0b9f222 100644 --- a/src/ConfigCat.Client.Tests/UserTests.cs +++ b/src/ConfigCat.Client.Tests/UserTests.cs @@ -125,37 +125,4 @@ public void CreateUser_ShouldSetIdentifier(string identifier, string expectedVal Assert.AreEqual(expectedValue, user.Identifier); Assert.AreEqual(expectedValue, user.GetAllAttributes()[nameof(User.Identifier)]); } - - [DataTestMethod] - [DataRow("datetime", "2023-09-19T11:01:35.0000000+00:00", "1695121295")] - [DataRow("datetime", "2023-09-19T13:01:35.0000000+02:00", "1695121295")] - [DataRow("datetime", "2023-09-19T11:01:35.0510886+00:00", "1695121295.051")] - [DataRow("datetime", "2023-09-19T13:01:35.0510886+02:00", "1695121295.051")] - [DataRow("number", "3", "3")] - [DataRow("number", "3.14", "3.14")] - [DataRow("number", "-1.23e-100", "-1.23e-100")] - [DataRow("stringlist", "a,,b,c", "[\"a\",\"\",\"b\",\"c\"]")] - public void HelperMethodsShouldWork(string type, string value, string expectedAttributeValue) - { - string actualAttributeValue; - switch (type) - { - case "datetime": - var dateTimeOffset = DateTimeOffset.ParseExact(value, "o", CultureInfo.InvariantCulture); - actualAttributeValue = User.AttributeValueFrom(dateTimeOffset); - break; - case "number": - var number = double.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture); - actualAttributeValue = User.AttributeValueFrom(number); - break; - case "stringlist": - var items = value.Split(','); - actualAttributeValue = User.AttributeValueFrom(items); - break; - default: - throw new InvalidOperationException(); - } - - Assert.AreEqual(expectedAttributeValue, actualAttributeValue); - } } diff --git a/src/ConfigCatClient/Evaluation/EvaluateContext.cs b/src/ConfigCatClient/Evaluation/EvaluateContext.cs index 4899f00d..8815489c 100644 --- a/src/ConfigCatClient/Evaluation/EvaluateContext.cs +++ b/src/ConfigCatClient/Evaluation/EvaluateContext.cs @@ -15,8 +15,8 @@ internal struct EvaluateContext [MemberNotNullWhen(true, nameof(UserAttributes))] public readonly bool IsUserAvailable => this.user is not null; - private IReadOnlyDictionary? userAttributes; - public IReadOnlyDictionary? UserAttributes => this.userAttributes ??= this.user?.GetAllAttributes(); + private IReadOnlyDictionary? userAttributes; + public IReadOnlyDictionary? UserAttributes => this.userAttributes ??= this.user?.GetAllAttributes(); private List? visitedFlags; public List VisitedFlags => this.visitedFlags ??= new List(); diff --git a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs index a86eeed4..3607730e 100644 --- a/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs +++ b/src/ConfigCatClient/Evaluation/RolloutEvaluator.cs @@ -209,7 +209,7 @@ private bool TryEvaluatePercentageOptions(PercentageOption[] percentageOptions, logBuilder?.NewLine().Append($"Evaluating % options based on the User.{percentageOptionsAttributeName} attribute:"); - var sha1 = (context.Key + percentageOptionsAttributeValue).Sha1(); + var sha1 = (context.Key + UserAttributeValueToString(percentageOptionsAttributeValue)).Sha1(); // NOTE: this is equivalent to hashValue = int.Parse(sha1.ToHexString().Substring(0, 7), NumberStyles.HexNumber) % 100; var hashValue = @@ -347,7 +347,7 @@ private bool EvaluateUserCondition(UserCondition condition, string contextSalt, var userAttributeName = condition.ComparisonAttribute ?? throw new InvalidOperationException("Comparison attribute name is missing."); - if (!(context.UserAttributes.TryGetValue(userAttributeName, out var userAttributeValue) && userAttributeValue.Length > 0)) + if (!context.UserAttributes.TryGetValue(userAttributeName, out var userAttributeValue) || userAttributeValue is string { Length: 0 }) { this.logger.UserObjectAttributeIsMissing(condition.ToString(), context.Key, userAttributeName); error = string.Format(CultureInfo.InvariantCulture, MissingUserAttributeError, userAttributeName); @@ -359,63 +359,64 @@ private bool EvaluateUserCondition(UserCondition condition, string contextSalt, { case UserComparator.TextEquals: case UserComparator.TextNotEquals: - return EvaluateTextEquals(userAttributeValue!, condition.StringValue, negate: comparator == UserComparator.TextNotEquals); + var text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateTextEquals(text, condition.StringValue, negate: comparator == UserComparator.TextNotEquals); case UserComparator.SensitiveTextEquals: case UserComparator.SensitiveTextNotEquals: - return EvaluateSensitiveTextEquals(userAttributeValue!, condition.StringValue, + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveTextEquals(text, condition.StringValue, EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, negate: comparator == UserComparator.SensitiveTextNotEquals); case UserComparator.IsOneOf: case UserComparator.IsNotOneOf: - return EvaluateIsOneOf(userAttributeValue!, condition.StringListValue, negate: comparator == UserComparator.IsNotOneOf); + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateIsOneOf(text, condition.StringListValue, negate: comparator == UserComparator.IsNotOneOf); case UserComparator.SensitiveIsOneOf: case UserComparator.SensitiveIsNotOneOf: - return EvaluateSensitiveIsOneOf(userAttributeValue!, condition.StringListValue, + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveIsOneOf(text, condition.StringListValue, EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, negate: comparator == UserComparator.SensitiveIsNotOneOf); case UserComparator.TextStartsWithAnyOf: case UserComparator.TextNotStartsWithAnyOf: - return EvaluateTextSliceEqualsAnyOf(userAttributeValue!, condition.StringListValue, startsWith: true, negate: comparator == UserComparator.TextNotStartsWithAnyOf); + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateTextSliceEqualsAnyOf(text, condition.StringListValue, startsWith: true, negate: comparator == UserComparator.TextNotStartsWithAnyOf); case UserComparator.SensitiveTextStartsWithAnyOf: case UserComparator.SensitiveTextNotStartsWithAnyOf: - return EvaluateSensitiveTextSliceEqualsAnyOf(userAttributeValue!, condition.StringListValue, + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveTextSliceEqualsAnyOf(text, condition.StringListValue, EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, startsWith: true, negate: comparator == UserComparator.SensitiveTextNotStartsWithAnyOf); case UserComparator.TextEndsWithAnyOf: case UserComparator.TextNotEndsWithAnyOf: - return EvaluateTextSliceEqualsAnyOf(userAttributeValue!, condition.StringListValue, startsWith: false, negate: comparator == UserComparator.TextNotEndsWithAnyOf); + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateTextSliceEqualsAnyOf(text, condition.StringListValue, startsWith: false, negate: comparator == UserComparator.TextNotEndsWithAnyOf); case UserComparator.SensitiveTextEndsWithAnyOf: case UserComparator.SensitiveTextNotEndsWithAnyOf: - return EvaluateSensitiveTextSliceEqualsAnyOf(userAttributeValue!, condition.StringListValue, + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateSensitiveTextSliceEqualsAnyOf(text, condition.StringListValue, EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, startsWith: false, negate: comparator == UserComparator.SensitiveTextNotEndsWithAnyOf); case UserComparator.ContainsAnyOf: case UserComparator.NotContainsAnyOf: - return EvaluateContainsAnyOf(userAttributeValue!, condition.StringListValue, negate: comparator == UserComparator.NotContainsAnyOf); + text = GetUserAttributeValueAsText(userAttributeName, userAttributeValue, condition, context.Key); + return EvaluateContainsAnyOf(text, condition.StringListValue, negate: comparator == UserComparator.NotContainsAnyOf); case UserComparator.SemVerIsOneOf: case UserComparator.SemVerIsNotOneOf: - if (!SemVersion.TryParse(userAttributeValue!.Trim(), out var version, strict: true)) - { - error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid semantic version"); - return false; - } - return EvaluateSemVerIsOneOf(version, condition.StringListValue, negate: comparator == UserComparator.SemVerIsNotOneOf); + var version = GetUserAttributeValueAsSemVer(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateSemVerIsOneOf(version!, condition.StringListValue, negate: comparator == UserComparator.SemVerIsNotOneOf); case UserComparator.SemVerLess: case UserComparator.SemVerLessOrEquals: case UserComparator.SemVerGreater: case UserComparator.SemVerGreaterOrEquals: - if (!SemVersion.TryParse(userAttributeValue!.Trim(), out version, strict: true)) - { - error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid semantic version"); - return false; - } - return EvaluateSemVerRelation(version, comparator, condition.StringValue); + version = GetUserAttributeValueAsSemVer(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateSemVerRelation(version!, comparator, condition.StringValue); case UserComparator.NumberEquals: case UserComparator.NumberNotEquals: @@ -423,43 +424,23 @@ private bool EvaluateUserCondition(UserCondition condition, string contextSalt, case UserComparator.NumberLessOrEquals: case UserComparator.NumberGreater: case UserComparator.NumberGreaterOrEquals: - if (!double.TryParse(userAttributeValue!.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) - { - error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid decimal number"); - return false; - } - return EvaluateNumberRelation(number, comparator, condition.DoubleValue); + var number = GetUserAttributeValueAsNumber(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateNumberRelation(number, comparator, condition.DoubleValue); case UserComparator.DateTimeBefore: case UserComparator.DateTimeAfter: - if (!double.TryParse(userAttributeValue!.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out number)) - { - error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)"); - return false; - } - return EvaluateDateTimeRelation(number, condition.DoubleValue, before: comparator == UserComparator.DateTimeBefore); + number = GetUserAttributeValueAsUnixTimeSeconds(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateDateTimeRelation(number, condition.DoubleValue, before: comparator == UserComparator.DateTimeBefore); case UserComparator.ArrayContainsAnyOf: case UserComparator.ArrayNotContainsAnyOf: - var array = userAttributeValue!.DeserializeOrDefault(); - if (array is null) - { - error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid JSON string array"); - return false; - } - - return EvaluateArrayContainsAnyOf(array, condition.StringListValue, negate: comparator == UserComparator.ArrayNotContainsAnyOf); + var stringArray = GetUserAttributeValueAsStringArray(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateArrayContainsAnyOf(stringArray!, condition.StringListValue, negate: comparator == UserComparator.ArrayNotContainsAnyOf); case UserComparator.SensitiveArrayContainsAnyOf: case UserComparator.SensitiveArrayNotContainsAnyOf: - array = userAttributeValue!.DeserializeOrDefault(); - if (array is null) - { - error = HandleInvalidUserAttribute(condition, context.Key, userAttributeName, $"'{userAttributeValue}' is not a valid JSON string array"); - return false; - } - - return EvaluateSensitiveArrayContainsAnyOf(array, condition.StringListValue, + stringArray = GetUserAttributeValueAsStringArray(userAttributeName, userAttributeValue, condition, context.Key, out error); + return error is null && EvaluateSensitiveArrayContainsAnyOf(stringArray!, condition.StringListValue, EnsureConfigJsonSalt(context.Setting.ConfigJsonSalt), contextSalt, negate: comparator == UserComparator.SensitiveArrayNotContainsAnyOf); default: @@ -875,9 +856,102 @@ private static T EnsureComparisonValue([NotNull] T? value) return value ?? throw new InvalidOperationException("Comparison value is missing or invalid."); } - private string HandleInvalidUserAttribute(UserCondition condition, string key, string userAttributeName, string reason) + private static string UserAttributeValueToString(object attributeValue) + { + if (attributeValue is string text) + { + return text; + } + else if (attributeValue is string[] stringArray) + { + return stringArray.Serialize(); + } + else if (attributeValue.TryConvertNumericToDouble(out var number)) + { + return number.ToString(CultureInfo.InvariantCulture); + } + else if (attributeValue.TryConvertDateTimeToDateTimeOffset(out var dateTimeOffset)) + { + var unixTimeSeconds = DateTimeUtils.ToUnixTimeMilliseconds(dateTimeOffset.UtcDateTime) / 1000.0; + return unixTimeSeconds.ToString(CultureInfo.InvariantCulture); + } + + return Convert.ToString(attributeValue, CultureInfo.InvariantCulture) ?? string.Empty; + } + + private string GetUserAttributeValueAsText(string attributeName, object attributeValue, UserCondition condition, string key) + { + if (attributeValue is string text) + { + return text; + } + + text = UserAttributeValueToString(attributeValue); + this.logger.UserObjectAttributeIsAutoConverted(condition.ToString(), key, attributeName, text); + return text; + } + + private SemVersion? GetUserAttributeValueAsSemVer(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if (attributeValue is string text && SemVersion.TryParse(text.Trim(), out var version, strict: true)) + { + error = null; + return version; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid semantic version"); + return default; + } + + private double GetUserAttributeValueAsNumber(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if ((attributeValue.TryConvertNumericToDouble(out var number) + || attributeValue is string text && double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out number)) + && !double.IsNaN(number)) + { + error = null; + return number; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid decimal number"); + return default; + } + + private double GetUserAttributeValueAsUnixTimeSeconds(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if (attributeValue.TryConvertDateTimeToDateTimeOffset(out var dateTimeOffset)) + { + error = null; + return DateTimeUtils.ToUnixTimeMilliseconds(dateTimeOffset.UtcDateTime) / 1000.0; + } + else if ((attributeValue.TryConvertNumericToDouble(out var number) + || attributeValue is string text && double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out number)) + && !double.IsNaN(number)) + { + error = null; + return number; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)"); + return default; + } + + private string[]? GetUserAttributeValueAsStringArray(string attributeName, object attributeValue, UserCondition condition, string key, out string? error) + { + if (attributeValue is string[] stringArray + || attributeValue is string json && (stringArray = json.DeserializeOrDefault()!) is not null) + { + error = null; + return stringArray; + } + + error = HandleInvalidUserAttribute(condition, key, attributeName, $"'{attributeValue}' is not a valid string array"); + return default; + } + + private string HandleInvalidUserAttribute(UserCondition condition, string key, string attributeName, string reason) { - this.logger.UserObjectAttributeIsInvalid(condition.ToString(), key, reason, userAttributeName); - return string.Format(CultureInfo.InvariantCulture, InvalidUserAttributeError, userAttributeName, reason); + this.logger.UserObjectAttributeIsInvalid(condition.ToString(), key, reason, attributeName); + return string.Format(CultureInfo.InvariantCulture, InvalidUserAttributeError, attributeName, reason); } } diff --git a/src/ConfigCatClient/Extensions/ObjectExtensions.cs b/src/ConfigCatClient/Extensions/ObjectExtensions.cs index 50fae160..00606d83 100644 --- a/src/ConfigCatClient/Extensions/ObjectExtensions.cs +++ b/src/ConfigCatClient/Extensions/ObjectExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Runtime.CompilerServices; using ConfigCat.Client; #if USE_NEWTONSOFT_JSON @@ -149,6 +150,46 @@ private static TValue ConvertTo(this JsonValue value) #endif } + public static bool TryConvertNumericToDouble(this object value, out double number) + { + if (Type.GetTypeCode(value.GetType()) is + TypeCode.SByte or + TypeCode.Byte or + TypeCode.Int16 or + TypeCode.UInt16 or + TypeCode.Int32 or + TypeCode.UInt32 or + TypeCode.Int64 or + TypeCode.UInt64 or + TypeCode.Single or + TypeCode.Double or + TypeCode.Decimal) + { + number = ((IConvertible)value).ToDouble(CultureInfo.InvariantCulture); + return true; + } + + number = default; + return false; + } + + public static bool TryConvertDateTimeToDateTimeOffset(this object value, out DateTimeOffset dateTimeOffset) + { + if (value is DateTimeOffset dateTimeOffsetLocal) + { + dateTimeOffset = dateTimeOffsetLocal; + return true; + } + else if (value is DateTime dateTime) + { + dateTimeOffset = new DateTimeOffset(dateTime.Kind != DateTimeKind.Unspecified ? dateTime : DateTime.SpecifyKind(dateTime, DateTimeKind.Utc)); + return true; + } + + dateTimeOffset = default; + return false; + } + private static readonly object BoxedTrue = true; private static readonly object BoxedFalse = false; diff --git a/src/ConfigCatClient/Logging/LogMessages.cs b/src/ConfigCatClient/Logging/LogMessages.cs index 193e1239..f523a6c8 100644 --- a/src/ConfigCatClient/Logging/LogMessages.cs +++ b/src/ConfigCatClient/Logging/LogMessages.cs @@ -136,6 +136,11 @@ public static FormattableLogMessage UserObjectAttributeIsInvalid(this LoggerWrap $"Cannot evaluate condition ({condition}) for setting '{key}' ({reason}). Please check the User.{attributeName} attribute and make sure that its value corresponds to the comparison operator.", "CONDITION", "KEY", "REASON", "ATTRIBUTE_NAME"); + public static FormattableLogMessage UserObjectAttributeIsAutoConverted(this LoggerWrapper logger, string condition, string key, string attributeName, string attributeValue) => logger.LogInterpolated( + LogLevel.Warning, 3005, + $"Evaluation of condition ({condition}) for setting '{key}' may not produce the expected result (the User.{attributeName} attribute is not a string value, thus it was automatically converted to the string value '{attributeValue}'). Please make sure that using a non-string value was intended.", + "CONDITION", "KEY", "ATTRIBUTE_NAME", "ATTRIBUTE_VALUE"); + public static FormattableLogMessage ConfigServiceCannotInitiateHttpCalls(this LoggerWrapper logger) => logger.Log( LogLevel.Warning, 3200, "Client is in offline mode, it cannot initiate HTTP calls."); diff --git a/src/ConfigCatClient/User.cs b/src/ConfigCatClient/User.cs index 3a8c08cc..ca70dd88 100644 --- a/src/ConfigCatClient/User.cs +++ b/src/ConfigCatClient/User.cs @@ -1,8 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; -using ConfigCat.Client.Utils; -using System.Linq; #if USE_NEWTONSOFT_JSON using Newtonsoft.Json; @@ -15,49 +12,12 @@ namespace ConfigCat.Client; /// /// User Object. Contains user attributes which are used for evaluating targeting rules and percentage options. /// +/// +/// Please note that the class is not designed to be used as a DTO (data transfer object). +/// (Since the type of the property is polymorphic, it's not guaranteed that deserializing a serialized instance produces an instance with an identical or even valid data content.) +/// public class User { - /// - /// Converts the specified value to the format expected by datetime comparison operators (BEFORE/AFTER). - /// - /// The value to convert. - /// The User Object attribute value in the expected format. - public static string AttributeValueFrom(DateTimeOffset dateTime) - { - var unixTimeSeconds = DateTimeUtils.ToUnixTimeMilliseconds(dateTime.UtcDateTime) / 1000.0; - return unixTimeSeconds.ToString("0.###", CultureInfo.InvariantCulture); - } - - /// - /// Converts the specified value to the format expected by number comparison operators. - /// - /// The value to convert. - /// The User Object attribute value in the expected format. - public static string AttributeValueFrom(double number) - { - return number.ToString("g", CultureInfo.InvariantCulture); // format "g" allows scientific notation as well - } - - /// - /// Converts the specified items to the format expected by array comparison operators (ARRAY CONTAINS ANY OF/ARRAY NOT CONTAINS ANY OF). - /// - /// The items to convert. - /// The User Object attribute value in the expected format. - public static string AttributeValueFrom(params string[] items) - { - return AttributeValueFrom(items.AsEnumerable()); - } - - /// - /// Converts the specified items to the format expected by array comparison operators (ARRAY CONTAINS ANY OF/ARRAY NOT CONTAINS ANY OF). - /// - /// The items to convert. - /// The User Object attribute value in the expected format. - public static string AttributeValueFrom(IEnumerable items) - { - return (items ?? throw new ArgumentNullException(nameof(items))).Serialize(); - } - internal const string DefaultIdentifierValue = ""; /// @@ -75,23 +35,57 @@ public static string AttributeValueFrom(IEnumerable items) /// public string? Country { get; set; } - private IDictionary? custom; + private IDictionary? custom; /// /// Custom attributes of the user for advanced targeting rule definitions (e.g. user role, subscription type, etc.) /// - public IDictionary Custom + /// + /// The set of allowed attribute values depends on the comparison type of the condition which references the User Object attribute.
+ /// values are supported by all comparison types (in some cases they need to be provided in a specific format though).
+ /// Some of the comparison types work with other types of values, as descibed below. + /// + /// Text-based comparisons (EQUALS, IS ONE OF, etc.)
+ /// * accept values,
+ /// * all other values are automatically converted to string (a warning will be logged but evaluation will continue as normal). + ///
+ /// + /// SemVer-based comparisons (IS ONE OF, <, >=, etc.)
+ /// * accept values containing a properly formatted, valid semver value,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// + /// Number-based comparisons (=, <, >=, etc.)
+ /// * accept values (except for ) and all other numeric values which can safely be converted to ,
+ /// * accept values containing a properly formatted, valid value,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// + /// Date time-based comparisons (BEFORE / AFTER)
+ /// * accept or values, which are automatically converted to a second-based Unix timestamp,
+ /// * accept values (except for ) representing a second-based Unix timestamp and all other numeric values which can safely be converted to ,
+ /// * accept values containing a properly formatted, valid value,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// + /// String array-based comparisons (ARRAY CONTAINS ANY OF / ARRAY NOT CONTAINS ANY OF)
+ /// * accept arrays of ,
+ /// * accept values containing a valid JSON string which can be deserialized to an array of ,
+ /// * all other values are considered invalid (a warning will be logged and the currently evaluated targeting rule will be skipped). + ///
+ /// In case a non-string attribute value needs to be converted to during evaluation, it will always be done using the same format which is accepted by the comparisons. + ///
+ public IDictionary Custom { - get => this.custom ??= new Dictionary(); + get => this.custom ??= new Dictionary(); set => this.custom = value; } - /// /// Returns all attributes of the user. /// - public IReadOnlyDictionary GetAllAttributes() + public IReadOnlyDictionary GetAllAttributes() { - var result = new Dictionary(); + var result = new Dictionary(); result[nameof(Identifier)] = Identifier;