From f4ab41a035ec49abcc4f4c407357820122967daa Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Fri, 10 Oct 2025 15:51:12 +0200 Subject: [PATCH 1/4] Add test cases for all functions mentioned in the documentation https://learn.microsoft.com/en-us/power-platform/power-fx/formula-reference-formula-columns --- tests/XrmMockup365Test/TestFormulaFields.cs | 352 ++++++++++++++++++-- tests/XrmMockup365Test/UnitTestBase.cs | 107 ++++-- 2 files changed, 400 insertions(+), 59 deletions(-) diff --git a/tests/XrmMockup365Test/TestFormulaFields.cs b/tests/XrmMockup365Test/TestFormulaFields.cs index f549ae4d..fb3036ea 100644 --- a/tests/XrmMockup365Test/TestFormulaFields.cs +++ b/tests/XrmMockup365Test/TestFormulaFields.cs @@ -1,14 +1,23 @@ using DG.Tools.XrmMockup; using DG.XrmFramework.BusinessDomain.ServiceContext; +using Microsoft.PowerFx.Types; using Microsoft.Xrm.Sdk; using System; +using System.Globalization; +using System.Linq; using Xunit; +using TTask = System.Threading.Tasks.Task; + namespace DG.XrmMockupTest { - public class TestFormulaFields : UnitTestBase, IOrganizationServiceFactory + public class TestFormulaFields : UnitTestBase { - public TestFormulaFields(XrmMockupFixture fixture) : base(fixture) { } + private readonly IOrganizationServiceFactory serviceFactory; + + public TestFormulaFields(XrmMockupFixture fixture) : base(fixture) { + serviceFactory = new UnitTestOrganizationServiceFactory(this); + } [Theory] [InlineData("123", "1 + 1", "2")] @@ -16,9 +25,9 @@ public TestFormulaFields(XrmMockupFixture fixture) : base(fixture) { } [InlineData("123", "Concatenate(name, \" - \", Text(accountnumber))", "Test - 123")] [InlineData("123", "If(Decimal(accountnumber) > 0, name & \" - \" & accountnumber, \"No Account Number\")", "Test - 123")] [InlineData("0", "If(Decimal(accountnumber) > 0, name & \" - \" & accountnumber, \"No Account Number\")", "No Account Number")] - public async System.Threading.Tasks.Task CanEvaluateExpressionOnEntity(string accountNumber, string formula, string expected) + public async TTask CanEvaluateExpressionOnEntity(string accountNumber, string formula, string expected) { - var evaluator = new FormulaFieldEvaluator(this); + var evaluator = new FormulaFieldEvaluator(serviceFactory); var account = Create(new Account { Name = "Test", AccountNumber = accountNumber }); var result = await evaluator.Evaluate(formula, account); @@ -26,9 +35,9 @@ public async System.Threading.Tasks.Task CanEvaluateExpressionOnEntity(string ac } [Fact] - public async System.Threading.Tasks.Task CanEvaluateExpressionWithRelatedEntity() + public async TTask CanEvaluateExpressionWithRelatedEntity() { - var evaluator = new FormulaFieldEvaluator(this); + var evaluator = new FormulaFieldEvaluator(serviceFactory); var adminUserId = Guid.Parse("3b961284-cd7a-4fa3-af7e-89802e88dd5c"); var animal = Create(new dg_animal { dg_name = "Fluffy", OwnerId = new EntityReference(SystemUser.EntityLogicalName, adminUserId) }); @@ -41,48 +50,327 @@ public async System.Threading.Tasks.Task CanEvaluateExpressionWithRelatedEntity( Assert.Equal("Test: Fluffy eats Meatballs", result); } - [Fact(Skip = "Function not implemented in Eval yet")] - public async System.Threading.Tasks.Task CanEvaluateExpressionWithUtcToday() + [Theory] + [ClassData(typeof(FunctionCases))] + public async TTask CanEvaluateFunction(string formula, object expected) { - var evaluator = new FormulaFieldEvaluator(this); + var evaluator = new FormulaFieldEvaluator(serviceFactory); + var result = await evaluator.Evaluate(formula, new dg_animal()); - var result = await evaluator.Evaluate("UTCToday()", new dg_animal()); - Assert.NotNull(result); + if (result is ErrorValue error && expected is string errorString) + { + var errorMessage = string.Join("\n", error.Errors.Select(e => e.Message)); + Assert.Contains(errorString, errorMessage); + } + else if (result is DateTime resultDateTime) + { + var expectedDateTime = (expected is DateTime dt) + ? dt + : (expected is "DateTime.Now") + ? DateTime.Now + : throw new InvalidOperationException("Expected value is not a DateTime or 'DateTime.Now'"); + Assert.Equal(expectedDateTime, resultDateTime, TimeSpan.FromSeconds(1)); + } + else + { + Assert.Equal(expected, result); + } } - private TEntity Create(TEntity entity) where TEntity : Entity + [Theory] + [ClassData(typeof(TextFunctionCases))] + public async TTask CanEvaluateTextFunction(string formula, string expected) { - var id = orgAdminService.Create(entity); + var evaluator = new FormulaFieldEvaluator(serviceFactory); + var result = await evaluator.Evaluate(formula, new dg_animal()); - return orgAdminService.Retrieve(entity.LogicalName, id, new Microsoft.Xrm.Sdk.Query.ColumnSet(true)).ToEntity(); + Assert.Equal(expected, result); } - public IOrganizationService CreateOrganizationService(Guid? userId) + [Theory] + [InlineData("ISOWeekNum(Date(2025, 1, 1))", 1)] // Returns the ISO week number of a date/time value. + [InlineData("IsUTCToday(DateTime(2025, 1, 1, 12, 30, 40))", false)] // Checks whether a date/time value is sometime today in Coordinated Universal Time (UTC). + [InlineData("IsUTCToday(Now())", true)] + [InlineData("UTCNow()", "DateTime.UtcNow")] // Returns the current date/time value in Coordinated Universal Time (UTC). + [InlineData("UTCToday()", "DateTime.UtcNow.Date")] // Returns the current date-only value in Coordinated Universal Time (UTC). + public async TTask CanEvaluateDataverseDateTimeCustomFunctions(string formula, object expected) { - if (userId == null || userId == Guid.Empty) - { - return orgAdminService; - } - else if (userId == testUser1.Id) - { - return testUser1Service; - } - else if (userId == testUser2.Id) - { - return testUser2Service; - } - else if (userId == testUser3.Id) + // Verify that the DataTime custom functions work as expected. + // https://learn.microsoft.com/en-us/power-platform/power-fx/formula-reference-formula-columns + + var evaluator = new FormulaFieldEvaluator(serviceFactory); + var result = await evaluator.Evaluate(formula, new dg_animal()); + + if (result is DateTime resultDateTime) { - return testUser3Service; + DateTime? expectedDateTime; + if (expected is "DateTime.UtcNow") + { + expectedDateTime = DateTime.UtcNow; + } + else if (expected is "DateTime.UtcNow.Date") + { + expectedDateTime = DateTime.UtcNow.Date; + } + else if (expected is DateTime dt) + { + expectedDateTime = dt; + } + else + { + throw new Exception("Expected value is not a DateTime or 'DateTime.UtcNow'/'DateTime.UtcNow.Date'"); + } + + Assert.Equal(expectedDateTime.Value, resultDateTime, TimeSpan.FromSeconds(1)); } - else if (userId == testUser4.Id) + else if (result is decimal resultDecimal && expected is int expectedInt) { - return testUser4Service; + Assert.Equal(expectedInt, resultDecimal); } else { - throw new ArgumentException($"Unknown userId: {userId}"); + Assert.Equal(expected, result); } } } + + public class FunctionCases : TheoryData + { + public FunctionCases() + { + // Absolute value of a number. + Add("Abs(-10.5)", 10.5m); + + // Boolean logic AND. Returns true if all arguments are true. You can also use the && operator. + Add("And(true, true)", true); + Add("And(true, false)", false); + Add("And(false, false)", false); + Add("And(false, true)", false); + Add("true && true", true); + + // Calculates the average of a table expression or a set of arguments. + Add("Average(2, 6)", 4m); + + // Returns a blank value that can be used to insert a NULL value in a data source. + Add("Blank()", null); + + // Translates a character code into a string. + Add("Char(42)", "*"); + + // Concatenates strings. + Add("Concatenate(\"Hello,\", \" \", \"World!\")", "Hello, World!"); + + // Adds days, months, quarters, or years to a date/time value. + Add("DateAdd(Date(2025, 1, 1), 5, TimeUnit.Days)", new DateTime(2025, 1, 6)); + Add("DateAdd(Date(2025, 1, 1), 1, TimeUnit.Months)", new DateTime(2025, 2, 1)); + Add("DateAdd(Date(2025, 1, 1), 1, TimeUnit.Quarters)", new DateTime(2025, 4, 1)); + Add("DateAdd(Date(2025, 1, 1), 1, TimeUnit.Years)", new DateTime(2026, 1, 1)); + + // Subtracts two date values, and shows the result in days, months, quarters, or years. + Add("DateDiff(Date(2025, 2, 1), Date(2025, 1, 1), TimeUnit.Months)", -1m); + + // Retrieves the day portion of a date/time value. + Add("Day(Date(2025, 1, 5))", 5m); + + // Converts a string to a decimal number. + Add("Decimal(\"10.5\")", 10.5m); + + // Checks whether a text string ends with another text string. + Add("EndsWith(\"some string\", \"string\")", true); + Add("EndsWith(\"some string\", \"nooooo\")", false); + + // Returns e raised to a power. + Add("Exp(0)", 1d); + + // Converts a string to a floating point number. + Add("Float(\"10.5\")", 10.5d); + + // Returns the hour portion of a date/time value. + Add("Hour(DateTime(2025, 1, 1, 12, 30, 40))", 12m); + + // Returns one value if a condition is true and another value if not. + Add("If(true, \"Yes\", \"No\")", "Yes"); + Add("If(false, \"Yes\", \"No\")", "No"); + + // Detects errors and provides an alternative value or takes action. + Add("IfError(Error(\"FAULT\"), \"Caught!\")", "Caught!"); + + // Rounds down to the nearest integer. + Add("Int(10.5)", 10m); + + // Checks for a blank value. + Add("IsBlank(Blank())", true); + Add("IsBlank(\"Not blank\")", false); + + // Error() : Create a custom error or pass through an error. + // IsError() : Checks for an error. + Add("IsError(Error(\"FAULT\"))", true); + Add("IsError(\"No error\")", false); + + // Returns the left-most portion of a string. + Add("Left(\"Hello, world!\", 5)", "Hello"); + + // Returns the length of a string. + Add("Len(\"Hello, world!\")", 13m); + + // Returns the natural log. + Add("Ln(Exp(1))", 1d); + Add("Ln(1)", 0d); + Add("Ln(0)", "The function 'Ln' returned a non-finite number."); + Add("Round(Ln(2), 2)", 0.69d); + + // Converts letters in a string of text to all lowercase. + Add("Lower(\"HELLO, world\")", "hello, world"); + + // Maximum value of a table expression or a set of arguments. + Add("Max(1, 2, 3)", 3m); + + // Returns the middle portion of a string. + Add("Mid(\"Hello, world!\", 6, 2)", ", "); + + // Minimum value of a table expression or a set of arguments. + Add("Min(1, 2, 3)", 1m); + + // Retrieves the minute portion of a date/time value. + Add("Minute(DateTime(2025, 1, 1, 12, 30, 40))", 30m); + + // Returns the remainder after a dividend is divided by a divisor. + Add("Mod(42, 2)", 0m); + Add("Mod(87, 14)", 3m); + + // Retrieves the month portion of a date/time value. + Add("Month(DateTime(2025, 1, 1, 12, 30, 40))", 1m); + + // Boolean logic NOT. Returns true if its argument is false, and returns false if its argument is true. You can also use the ! operator. + Add("Not(true)", false); + Add("Not(false)", true); + + // Returns the current date/time value in the user's time zone. + Add("Now()", "DateTime.Now"); + + // Boolean logic OR. Returns true if any of its arguments are true. You can also use the || operator. + Add("Or(true, false)", true); + Add("Or(true, true)", true); + Add("Or(false, true)", true); + Add("Or(false, false)", false); + Add("true || false", true); + + // Returns a number raised to a power. You can also use the ^ operator. + Add("Power(10, 2)", 100d); + Add("Power(2, 10)", 1024d); + Add("2 ^ 10", 1024d); + + // Replaces part of a string with another string, by starting position of the string. + Add("Replace(\"Hello, world!\", 1, 5, \"Goodbye\")", "Goodbye, world!"); + + // Returns the right-most portion of a string. + Add("Right(\"Hello, world!\", 6)", "world!"); + + // Exp(): Returns e raised to a power. + // Round(): Rounds to the closest number. + Add("Round(Exp(2), 3)", 7.389d); + Add("Round(Exp(2), 2)", 7.39d); + + // Rounds down to the largest previous number. + Add("RoundDown(10.57443, 2)", 10.57m); + Add("RoundDown(10.57443, 1)", 10.5m); + Add("RoundDown(10.57443, 0)", 10m); + + // Rounds up to the smallest next number. + Add("RoundUp(10.57443, 2)", 10.58m); + Add("RoundUp(10.57443, 1)", 10.6m); + Add("RoundUp(10.57443, 0)", 11m); + + // Retrieves the second portion of a date/time value. + Add("Second(DateTime(2025, 1, 1, 12, 30, 40))", 40m); + + // Returns the square root of a number. + Add("Sqrt(16)", 4d); + + // Checks if a text string begins with another text string. + Add("StartsWith(\"Hello, world!\", \"Hello\")", true); + Add("StartsWith(\"Hello, world!\", \"world!\")", false); + + // Replaces part of a string with another string, by matching strings. + Add("Substitute(\"Hello, world!\", \"Hello\", \"Goodbye\")", "Goodbye, world!"); + + // Calculates the sum of a table expression or a set of arguments. + Add("Sum(1, 2, 3, 4, 5)", 15m); + + // Matches with a set of values and then evaluates a corresponding formula. + Add("Switch(10, 10, \"Result 1\", 20, \"Result 2\", \"Result 3\")", "Result 1"); + Add("Switch(20, 10, \"Result 1\", 20, \"Result 2\", \"Result 3\")", "Result 2"); + Add("Switch(30, 10, \"Result 1\", 20, \"Result 2\", \"Result 3\")", "Result 3"); + + // Removes extra spaces from the ends and interior of a string of text. + Add("Trim(\" Hello, world! \")", "Hello, world!"); + + // Truncates the number to only the integer portion by removing any decimal portion. + Add("Trunc(10.5)", 10m); + + // Removes extra spaces from the ends of a string of text only. + Add("TrimEnds(\" Hello, world! \")", "Hello, world!"); + + // Converts letters in a string of text to all uppercase. + Add("Upper(\"Hello, WORLD!\")", "HELLO, WORLD!"); + + // Converts a string to a number. + Add("Value(10)", 10m); + Add("Value(10.5)", 10.5m); + Add("Value(10.575)", 10.575m); + + // Retrieves the weekday portion of a date/time value. + Add("Weekday(DateTime(2025, 1, 1, 12, 30, 40))", 4m); + + // Returns the week number of a date/time value. + Add("WeekNum(DateTime(2025, 1, 1, 12, 30, 40))", 1m); + Add("WeekNum(DateTime(2025, 1, 1, 12, 30, 40), StartOfWeek.Sunday)", 1m); + Add("WeekNum(DateTime(2025, 1, 1, 12, 30, 40), StartOfWeek.Monday)", 1m); + + // Retrieves the year portion of a date/time value. + Add("Year(DateTime(2025, 1, 1, 12, 30, 40))", 2025m); + } + } + + public class TextFunctionCases : TheoryData + { + public TextFunctionCases() + { + // Converts any value and formats a number or date/time value to a string of text. + Add("Text(1234.59, \"####.#\")", "1234.6"); + Add("Text(8.9, \"#.000\")", "8.900"); + Add("Text(0.631, \"0.#\")", "0.6"); + Add("Text(12, \"#.0#\")", "12.0"); + Add("Text(1234.568, \"#.0#\")", "1234.57"); + Add("Text(12000, \"$ #,###\")", "$ 12,000"); + Add("Text(1200000, \"$ #,###\")", "$ 1,200,000"); + + // TODO: Something funky is going on with the culture handling here. + // TODO: Also, seems LongTime24 just... doesn't? It returns the same as LongTime. + + var testDateTime = new DateTime(2015, 11, 23, 14, 37, 47); + Add("Text(DateTime(2015, 11, 23, 14, 37, 47), DateTimeFormat.LongDate)", testDateTime.ToString("D", CultureInfo.InvariantCulture)); + Add("Text(DateTime(2015, 11, 23, 14, 37, 47), DateTimeFormat.LongDateTime)", testDateTime.ToString("F", CultureInfo.InvariantCulture)); + Add("Text(DateTime(2015, 11, 23, 14, 37, 47), DateTimeFormat.LongTime24)", "14:37:47"); + Add("Text(DateTime(2015, 11, 23, 14, 37, 47), DateTimeFormat.ShortDate)", "11/23/2015"); + Add("Text(DateTime(2015, 11, 23, 14, 37, 47), \"d-mmm-yy\")", "23-Nov-15"); + + // TODO: This errors out with invalid date time + //Add("Text(1448318857 * 1000, \"mmm. dd, yyyy (hh:mm:ss AM/PM)\")", "Nov. 23, 2015 (02:47:37 PM)"); + + // TODO: The in-format specifier doesn't seem to be supported on the parser currently + //Add("Text(1234567, 89; \"[$-da-DK]# ###,## €\")", "1.234.567,89 €"); + Add("Text(1234567.89, \"#,###.## €\", \"da-DK\")", "1.234.567,89 €"); + Add("Text(Date(2016, 1, 31), \"dddd mmmm d\")", "Sunday January 31"); + Add("Text(Date(2016, 1, 31), \"dddd mmmm d\", \"es-ES\")", "domingo enero 31"); + + Add("Text(1234567.89)", "1234567.89"); + + // TODO: This also fails + //Add("Text(DateTimeValue(\"01/04/2003\"))", "1/4/2003 12:00 AM"); + + Add("Text(true)", "true"); + Add("Text(GUID(\"f8b10550-0f12-4f08-9aa3-bb10958bc3ff\"))", "f8b10550-0f12-4f08-9aa3-bb10958bc3ff"); + } + } } diff --git a/tests/XrmMockup365Test/UnitTestBase.cs b/tests/XrmMockup365Test/UnitTestBase.cs index 4c056603..3f7aeaab 100644 --- a/tests/XrmMockup365Test/UnitTestBase.cs +++ b/tests/XrmMockup365Test/UnitTestBase.cs @@ -12,37 +12,78 @@ namespace DG.XrmMockupTest { - public class UnitTestBase : IClassFixture + public abstract class ServiceWrapper { - private static DateTime _startTime { get; set; } - #if DATAVERSE_SERVICE_CLIENT - protected IOrganizationServiceAsync2 orgAdminUIService; - protected IOrganizationServiceAsync2 orgAdminService; - protected IOrganizationServiceAsync2 orgGodService; - protected IOrganizationServiceAsync2 orgRealDataService; - - protected IOrganizationServiceAsync2 testUser1Service; - protected IOrganizationServiceAsync2 testUser2Service; - protected IOrganizationServiceAsync2 testUser3Service; - protected IOrganizationServiceAsync2 testUser4Service; + public IOrganizationServiceAsync2 orgAdminUIService { get; protected set; } + public IOrganizationServiceAsync2 orgAdminService { get; protected set; } + public IOrganizationServiceAsync2 orgGodService { get; protected set; } + public IOrganizationServiceAsync2 orgRealDataService { get; protected set; } + + public IOrganizationServiceAsync2 testUser1Service { get; protected set; } + public IOrganizationServiceAsync2 testUser2Service { get; protected set; } + public IOrganizationServiceAsync2 testUser3Service { get; protected set; } + public IOrganizationServiceAsync2 testUser4Service { get; protected set; } #else - protected IOrganizationService orgAdminUIService; - protected IOrganizationService orgAdminService; - protected IOrganizationService orgGodService; - protected IOrganizationService orgRealDataService; - - protected IOrganizationService testUser1Service; - protected IOrganizationService testUser2Service; - protected IOrganizationService testUser3Service; - protected IOrganizationService testUser4Service; + public IOrganizationService orgAdminUIService { get; protected set; } + public IOrganizationService orgAdminService { get; protected set; } + public IOrganizationService orgGodService { get; protected set; } + public IOrganizationService orgRealDataService { get; protected set; } + + public IOrganizationService testUser1Service { get; protected set; } + public IOrganizationService testUser2Service { get; protected set; } + public IOrganizationService testUser3Service { get; protected set; } + public IOrganizationService testUser4Service { get; protected set; } #endif - protected Entity testUser1; - protected Entity testUser2; - protected Entity testUser3; - protected Entity testUser4; - protected Entity testUser5; + public Entity testUser1 { get; protected set; } + public Entity testUser2 { get; protected set; } + public Entity testUser3 { get; protected set; } + public Entity testUser4 { get; protected set; } + public Entity testUser5 { get; protected set; } + } + + public class UnitTestOrganizationServiceFactory : IOrganizationServiceFactory + { + public UnitTestOrganizationServiceFactory(ServiceWrapper services) + { + Services = services; + } + + private ServiceWrapper Services { get; } + + public IOrganizationService CreateOrganizationService(Guid? userId) + { + if (userId == null || userId == Guid.Empty) + { + return Services.orgAdminService; + } + else if (userId == Services.testUser1.Id) + { + return Services.testUser1Service; + } + else if (userId == Services.testUser2.Id) + { + return Services.testUser2Service; + } + else if (userId == Services.testUser3.Id) + { + return Services.testUser3Service; + } + else if (userId == Services.testUser4.Id) + { + return Services.testUser4Service; + } + else + { + throw new ArgumentException($"Unknown userId: {userId}"); + } + } + } + + public class UnitTestBase : ServiceWrapper, IClassFixture + { + private static DateTime _startTime { get; set; } protected Entity contactWriteAccessTeamTemplate; @@ -192,7 +233,19 @@ private void CreateAccessTeamTemplate(string name, int objectTypeCode, params Ac contactWriteAccessTeamTemplate["defaultaccessrightsmask"] = mask; contactWriteAccessTeamTemplate.Id = orgAdminService.Create(contactWriteAccessTeamTemplate); } - public void Dispose() + + protected TEntity Create(TEntity entity) where TEntity : Entity + { + return Create(orgAdminService, entity); + } + + protected static TEntity Create(IOrganizationService service, TEntity entity) where TEntity : Entity + { + var id = service.Create(entity); + return service.Retrieve(entity.LogicalName, id, new Microsoft.Xrm.Sdk.Query.ColumnSet(true)).ToEntity(); + } + + public virtual void Dispose() { // No need to reset environment since each test has its own instance // The instance will be garbage collected automatically From 7785207cf28f6b862923f21f06dc5a1bf9222288 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Mon, 13 Oct 2025 10:40:39 +0200 Subject: [PATCH 2/4] Implement support for the 4 functions not in standard, but in dataverse --- .../CustomFunction/ISOWeekNumFunction.cs | 40 +++++++++++++++++++ .../CustomFunction/IsUTCTodayFunction.cs | 23 +++++++++++ .../CustomFunction/UTCNowFunction.cs | 18 +++++++++ .../CustomFunction/UTCTodayFunction.cs | 19 +++++++++ src/XrmMockup365/FormulaFieldEvaluator.cs | 25 ++++++++++-- 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs create mode 100644 src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs create mode 100644 src/XrmMockup365/CustomFunction/UTCNowFunction.cs create mode 100644 src/XrmMockup365/CustomFunction/UTCTodayFunction.cs diff --git a/src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs b/src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs new file mode 100644 index 00000000..b5dfab42 --- /dev/null +++ b/src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs @@ -0,0 +1,40 @@ +using Microsoft.PowerFx; +using Microsoft.PowerFx.Interpreter; +using Microsoft.PowerFx.Types; +using System; +using System.Globalization; + +namespace DG.Tools.XrmMockup.CustomFunction +{ + internal class ISOWeekNumFunction : ReflectionFunction + { + public ISOWeekNumFunction() + : base("ISOWeekNum", FormulaType.Decimal, FormulaType.DateTime) + { + } + public static DecimalValue Execute(DateTimeValue date) + { + var utcDate = date?.GetConvertedValue(TimeZoneInfo.Utc); + if (utcDate == null || utcDate.Value <= DateTime.MinValue) + throw new CustomFunctionErrorException("Invalid date or time value", ErrorKind.InvalidArgument); + +#if DATAVERSE_SERVICE_CLIENT + var weekNumber = ISOWeek.GetWeekOfYear(utcDate.Value); +#else + // .NET Framework does not have ISOWeek class + // Implementing ISO 8601 week date algorithm + // https://learn.microsoft.com/en-us/archive/blogs/shawnste/iso-8601-week-of-year-format-in-microsoft-net + + var dayOfWeek = utcDate.Value.DayOfWeek; + if (dayOfWeek >= DayOfWeek.Monday && dayOfWeek <= DayOfWeek.Wednesday) + { + utcDate = utcDate.Value.AddDays(3); + } + + var weekNumber = CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(utcDate.Value, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday); +#endif + + return FormulaValue.New(weekNumber); + } + } +} diff --git a/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs b/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs new file mode 100644 index 00000000..6319bbd4 --- /dev/null +++ b/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs @@ -0,0 +1,23 @@ +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using System; + +namespace DG.Tools.XrmMockup.CustomFunction +{ + internal class IsUTCTodayFunction : ReflectionFunction + { + public IsUTCTodayFunction() + : base("IsUTCToday", FormulaType.Boolean, FormulaType.DateTime) + { + } + public static BooleanValue Execute(DateTimeValue date) + { + if (date == null) + return FormulaValue.New(false); + + var utcToday = DateTime.UtcNow.Date; + var inputValue = date.GetConvertedValue(TimeZoneInfo.Utc); + return FormulaValue.New(inputValue.Date == utcToday); + } + } +} diff --git a/src/XrmMockup365/CustomFunction/UTCNowFunction.cs b/src/XrmMockup365/CustomFunction/UTCNowFunction.cs new file mode 100644 index 00000000..99d7a73f --- /dev/null +++ b/src/XrmMockup365/CustomFunction/UTCNowFunction.cs @@ -0,0 +1,18 @@ +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using System; + +namespace DG.Tools.XrmMockup.CustomFunction +{ + internal class UTCNowFunction : ReflectionFunction + { + public UTCNowFunction() + : base("UTCNow", FormulaType.DateTime) + { + } + public static DateTimeValue Execute() + { + return FormulaValue.New(DateTime.UtcNow); + } + } +} diff --git a/src/XrmMockup365/CustomFunction/UTCTodayFunction.cs b/src/XrmMockup365/CustomFunction/UTCTodayFunction.cs new file mode 100644 index 00000000..05b7234f --- /dev/null +++ b/src/XrmMockup365/CustomFunction/UTCTodayFunction.cs @@ -0,0 +1,19 @@ +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using System; + +namespace DG.Tools.XrmMockup.CustomFunction +{ + internal class UTCTodayFunction : ReflectionFunction + { + public UTCTodayFunction() + : base("UTCToday", FormulaType.DateTime) + { + } + + public static DateTimeValue Execute() + { + return FormulaValue.New(DateTime.UtcNow.Date); + } + } +} diff --git a/src/XrmMockup365/FormulaFieldEvaluator.cs b/src/XrmMockup365/FormulaFieldEvaluator.cs index 3dafe4f9..0bc59726 100644 --- a/src/XrmMockup365/FormulaFieldEvaluator.cs +++ b/src/XrmMockup365/FormulaFieldEvaluator.cs @@ -1,9 +1,11 @@ -using Microsoft.PowerFx.Dataverse; +using DG.Tools.XrmMockup.CustomFunction; using Microsoft.PowerFx; +using Microsoft.PowerFx.Dataverse; using Microsoft.Xrm.Sdk; -using System.Threading.Tasks; -using System.Threading; +using System.Collections.Generic; using System.Globalization; +using System.Threading; +using System.Threading.Tasks; namespace DG.Tools.XrmMockup { @@ -12,6 +14,14 @@ internal class FormulaFieldEvaluator private readonly IOrganizationService _organizationService; private readonly DataverseConnection _dataverseConnection; + private readonly List _customFunctions = new List + { + new UTCTodayFunction(), + new UTCNowFunction(), + new IsUTCTodayFunction(), + new ISOWeekNumFunction() + }; + public FormulaFieldEvaluator(IOrganizationServiceFactory serviceFactory) { _organizationService = serviceFactory.CreateOrganizationService(null); @@ -22,7 +32,14 @@ public async Task Evaluate(string formula, Entity thisEntity) { var rowScopeSymbols = _dataverseConnection.GetRowScopeSymbols(thisEntity.LogicalName, true); - var engine = new RecalcEngine(); + var config = new PowerFxConfig(); + foreach (var func in _customFunctions) + { + config.AddFunction(func); + } + + var engine = new RecalcEngine(config); + var combinedSymbols = ReadOnlySymbolTable.Compose(rowScopeSymbols, _dataverseConnection.Symbols); var checkResult = engine.Check(formula, new ParserOptions(CultureInfo.InvariantCulture), combinedSymbols); checkResult.ThrowOnErrors(); From 19f1dc9c73efc6a13a2796bae494a5e0d5314ddc Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Mon, 13 Oct 2025 12:49:37 +0200 Subject: [PATCH 3/4] Add TimeOffset support for UTC PowerFX functions --- src/XrmMockup365/Core.cs | 2 +- .../CustomFunction/ISOWeekNumFunction.cs | 9 ++++- .../CustomFunction/IsUTCTodayFunction.cs | 15 +++++--- .../CustomFunction/UTCNowFunction.cs | 10 +++-- .../CustomFunction/UTCTodayFunction.cs | 10 +++-- src/XrmMockup365/FormulaFieldEvaluator.cs | 19 +++------- tests/XrmMockup365Test/TestFormulaFields.cs | 38 ++++++++++++++++--- 7 files changed, 70 insertions(+), 33 deletions(-) diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index 86b99dc0..23188561 100644 --- a/src/XrmMockup365/Core.cs +++ b/src/XrmMockup365/Core.cs @@ -1361,7 +1361,7 @@ internal async System.Threading.Tasks.Task ExecuteFormulaFields(EntityMetadata e continue; } - entity[attr.LogicalName] = await FormulaFieldEvaluator.Evaluate(definition, entity); + entity[attr.LogicalName] = await FormulaFieldEvaluator.Evaluate(definition, entity, TimeOffset); } } diff --git a/src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs b/src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs index b5dfab42..49573fae 100644 --- a/src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs +++ b/src/XrmMockup365/CustomFunction/ISOWeekNumFunction.cs @@ -8,16 +8,21 @@ namespace DG.Tools.XrmMockup.CustomFunction { internal class ISOWeekNumFunction : ReflectionFunction { - public ISOWeekNumFunction() + private readonly TimeSpan timeOffset; + + public ISOWeekNumFunction(TimeSpan timeOffset) : base("ISOWeekNum", FormulaType.Decimal, FormulaType.DateTime) { + this.timeOffset = timeOffset; } - public static DecimalValue Execute(DateTimeValue date) + public DecimalValue Execute(DateTimeValue date) { var utcDate = date?.GetConvertedValue(TimeZoneInfo.Utc); if (utcDate == null || utcDate.Value <= DateTime.MinValue) throw new CustomFunctionErrorException("Invalid date or time value", ErrorKind.InvalidArgument); + utcDate = utcDate.Value.Add(timeOffset); + #if DATAVERSE_SERVICE_CLIENT var weekNumber = ISOWeek.GetWeekOfYear(utcDate.Value); #else diff --git a/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs b/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs index 6319bbd4..adb17f97 100644 --- a/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs +++ b/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs @@ -1,4 +1,5 @@ using Microsoft.PowerFx; +using Microsoft.PowerFx.Interpreter; using Microsoft.PowerFx.Types; using System; @@ -6,16 +7,20 @@ namespace DG.Tools.XrmMockup.CustomFunction { internal class IsUTCTodayFunction : ReflectionFunction { - public IsUTCTodayFunction() + private readonly TimeSpan timeOffset; + + public IsUTCTodayFunction(TimeSpan timeOffset) : base("IsUTCToday", FormulaType.Boolean, FormulaType.DateTime) { + this.timeOffset = timeOffset; } - public static BooleanValue Execute(DateTimeValue date) + public BooleanValue Execute(DateTimeValue date) { - if (date == null) - return FormulaValue.New(false); + var utcDate = date?.GetConvertedValue(TimeZoneInfo.Utc); + if (utcDate == null || utcDate <= DateTime.MinValue) + throw new CustomFunctionErrorException("Invalid date or time value", ErrorKind.InvalidArgument); - var utcToday = DateTime.UtcNow.Date; + var utcToday = DateTime.UtcNow.Add(timeOffset).Date; var inputValue = date.GetConvertedValue(TimeZoneInfo.Utc); return FormulaValue.New(inputValue.Date == utcToday); } diff --git a/src/XrmMockup365/CustomFunction/UTCNowFunction.cs b/src/XrmMockup365/CustomFunction/UTCNowFunction.cs index 99d7a73f..8a7d4ffe 100644 --- a/src/XrmMockup365/CustomFunction/UTCNowFunction.cs +++ b/src/XrmMockup365/CustomFunction/UTCNowFunction.cs @@ -6,13 +6,17 @@ namespace DG.Tools.XrmMockup.CustomFunction { internal class UTCNowFunction : ReflectionFunction { - public UTCNowFunction() + private readonly TimeSpan timeOffset; + + public UTCNowFunction(TimeSpan timeOffset) : base("UTCNow", FormulaType.DateTime) { + this.timeOffset = timeOffset; } - public static DateTimeValue Execute() + public DateTimeValue Execute() { - return FormulaValue.New(DateTime.UtcNow); + var utcNow = DateTime.UtcNow.Add(timeOffset); + return FormulaValue.New(utcNow); } } } diff --git a/src/XrmMockup365/CustomFunction/UTCTodayFunction.cs b/src/XrmMockup365/CustomFunction/UTCTodayFunction.cs index 05b7234f..ba8ca3e2 100644 --- a/src/XrmMockup365/CustomFunction/UTCTodayFunction.cs +++ b/src/XrmMockup365/CustomFunction/UTCTodayFunction.cs @@ -6,14 +6,18 @@ namespace DG.Tools.XrmMockup.CustomFunction { internal class UTCTodayFunction : ReflectionFunction { - public UTCTodayFunction() + private readonly TimeSpan timeOffset; + + public UTCTodayFunction(TimeSpan timeOffset) : base("UTCToday", FormulaType.DateTime) { + this.timeOffset = timeOffset; } - public static DateTimeValue Execute() + public DateTimeValue Execute() { - return FormulaValue.New(DateTime.UtcNow.Date); + var utcNow = DateTime.UtcNow.Add(timeOffset); + return FormulaValue.New(utcNow.Date); } } } diff --git a/src/XrmMockup365/FormulaFieldEvaluator.cs b/src/XrmMockup365/FormulaFieldEvaluator.cs index 0bc59726..a6781a8d 100644 --- a/src/XrmMockup365/FormulaFieldEvaluator.cs +++ b/src/XrmMockup365/FormulaFieldEvaluator.cs @@ -2,6 +2,7 @@ using Microsoft.PowerFx; using Microsoft.PowerFx.Dataverse; using Microsoft.Xrm.Sdk; +using System; using System.Collections.Generic; using System.Globalization; using System.Threading; @@ -14,29 +15,21 @@ internal class FormulaFieldEvaluator private readonly IOrganizationService _organizationService; private readonly DataverseConnection _dataverseConnection; - private readonly List _customFunctions = new List - { - new UTCTodayFunction(), - new UTCNowFunction(), - new IsUTCTodayFunction(), - new ISOWeekNumFunction() - }; - public FormulaFieldEvaluator(IOrganizationServiceFactory serviceFactory) { _organizationService = serviceFactory.CreateOrganizationService(null); _dataverseConnection = SingleOrgPolicy.New(_organizationService); } - public async Task Evaluate(string formula, Entity thisEntity) + public async Task Evaluate(string formula, Entity thisEntity, TimeSpan timeOffset) { var rowScopeSymbols = _dataverseConnection.GetRowScopeSymbols(thisEntity.LogicalName, true); var config = new PowerFxConfig(); - foreach (var func in _customFunctions) - { - config.AddFunction(func); - } + config.AddFunction(new UTCTodayFunction(timeOffset)); + config.AddFunction(new UTCNowFunction(timeOffset)); + config.AddFunction(new IsUTCTodayFunction(timeOffset)); + config.AddFunction(new ISOWeekNumFunction(timeOffset)); var engine = new RecalcEngine(config); diff --git a/tests/XrmMockup365Test/TestFormulaFields.cs b/tests/XrmMockup365Test/TestFormulaFields.cs index fb3036ea..577f316c 100644 --- a/tests/XrmMockup365Test/TestFormulaFields.cs +++ b/tests/XrmMockup365Test/TestFormulaFields.cs @@ -30,7 +30,7 @@ public async TTask CanEvaluateExpressionOnEntity(string accountNumber, string fo var evaluator = new FormulaFieldEvaluator(serviceFactory); var account = Create(new Account { Name = "Test", AccountNumber = accountNumber }); - var result = await evaluator.Evaluate(formula, account); + var result = await evaluator.Evaluate(formula, account, TimeSpan.Zero); Assert.Equal(expected, result.ToString()); } @@ -43,10 +43,10 @@ public async TTask CanEvaluateExpressionWithRelatedEntity() var animal = Create(new dg_animal { dg_name = "Fluffy", OwnerId = new EntityReference(SystemUser.EntityLogicalName, adminUserId) }); var animalFood = Create(new dg_animalfood { dg_AnimalId = new EntityReference(dg_animal.EntityLogicalName, animal.Id), dg_name = "Meatballs" }); - var result = await evaluator.Evaluate("\"Test: \" & dg_animalowner", animal); + var result = await evaluator.Evaluate("\"Test: \" & dg_animalowner", animal, TimeSpan.Zero); Assert.Equal("Test: Fluffy is a very good animal, and Admin loves them very much", result); - result = await evaluator.Evaluate("If(dg_AnimalId.dg_name = \"Fluffy\", \"Test: \" & dg_AnimalId.dg_name & \" eats \" & dg_name, \"New telegraph, who dis?\")", animalFood); + result = await evaluator.Evaluate("If(dg_AnimalId.dg_name = \"Fluffy\", \"Test: \" & dg_AnimalId.dg_name & \" eats \" & dg_name, \"New telegraph, who dis?\")", animalFood, TimeSpan.Zero); Assert.Equal("Test: Fluffy eats Meatballs", result); } @@ -55,7 +55,7 @@ public async TTask CanEvaluateExpressionWithRelatedEntity() public async TTask CanEvaluateFunction(string formula, object expected) { var evaluator = new FormulaFieldEvaluator(serviceFactory); - var result = await evaluator.Evaluate(formula, new dg_animal()); + var result = await evaluator.Evaluate(formula, new Account(), TimeSpan.Zero); if (result is ErrorValue error && expected is string errorString) { @@ -82,7 +82,7 @@ public async TTask CanEvaluateFunction(string formula, object expected) public async TTask CanEvaluateTextFunction(string formula, string expected) { var evaluator = new FormulaFieldEvaluator(serviceFactory); - var result = await evaluator.Evaluate(formula, new dg_animal()); + var result = await evaluator.Evaluate(formula, new Account(), TimeSpan.Zero); Assert.Equal(expected, result); } @@ -99,7 +99,7 @@ public async TTask CanEvaluateDataverseDateTimeCustomFunctions(string formula, o // https://learn.microsoft.com/en-us/power-platform/power-fx/formula-reference-formula-columns var evaluator = new FormulaFieldEvaluator(serviceFactory); - var result = await evaluator.Evaluate(formula, new dg_animal()); + var result = await evaluator.Evaluate(formula, new Account(), TimeSpan.Zero); if (result is DateTime resultDateTime) { @@ -132,6 +132,32 @@ public async TTask CanEvaluateDataverseDateTimeCustomFunctions(string formula, o Assert.Equal(expected, result); } } + + [Fact] + public async TTask UTCNowRespectsOffset() + { + var evaluator = new FormulaFieldEvaluator(serviceFactory); + var result = await evaluator.Evaluate("UTCNow()", new Account(), TimeSpan.FromDays(1)); + var resultDateTime = (DateTime)result; + Assert.Equal(DateTime.UtcNow.AddDays(1), resultDateTime, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async TTask UTCTodayRespectsOffset() + { + var evaluator = new FormulaFieldEvaluator(serviceFactory); + var result = await evaluator.Evaluate("UTCToday()", new Account(), TimeSpan.FromDays(1)); + var resultDateTime = (DateTime)result; + Assert.Equal(DateTime.UtcNow.Date.AddDays(1), resultDateTime, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async TTask IsUTCTodayRespectsOffset() + { + var evaluator = new FormulaFieldEvaluator(serviceFactory); + var result = await evaluator.Evaluate("IsUTCToday(UTCNow())", new Account(), TimeSpan.FromDays(1)); + Assert.Equal(true, result); + } } public class FunctionCases : TheoryData From 7bcbb03e996c8db7dc0b742f1220a7a8e1499a1f Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Mon, 13 Oct 2025 13:07:49 +0200 Subject: [PATCH 4/4] No need to convert date twice Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs b/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs index adb17f97..b59dff94 100644 --- a/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs +++ b/src/XrmMockup365/CustomFunction/IsUTCTodayFunction.cs @@ -21,8 +21,7 @@ public BooleanValue Execute(DateTimeValue date) throw new CustomFunctionErrorException("Invalid date or time value", ErrorKind.InvalidArgument); var utcToday = DateTime.UtcNow.Add(timeOffset).Date; - var inputValue = date.GetConvertedValue(TimeZoneInfo.Utc); - return FormulaValue.New(inputValue.Date == utcToday); + return FormulaValue.New(utcDate.Value.Date == utcToday); } } }