diff --git a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs index aa705a3af5..9b33d37450 100644 --- a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs +++ b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs @@ -87,20 +87,31 @@ public static FormulaValue FromJson(JsonElement element, FormulaValueJsonSeriali return FromJson(element, settings, new FormulaValueJsonSerializerWorkingData(), formulaType); } - // // caller verified element is non-null and is of type string + // // caller verified element is non-null and is of type string internal static FormulaValue ParseDate(JsonElement element, FormulaType targetType, Func funcParse) { - var strValue = element.GetString(); + var strValue = element.GetString(); if (string.IsNullOrWhiteSpace(strValue)) { return FormulaValue.NewBlank(targetType); } - // Any exceptions will be caught at higher level. - var dateTime = element.GetDateTime(); + // Use DateTimeOffset.TryParse for consistent, machine-independent parsing that matches + // Power Automate / Logic Apps behavior. Treats offset-less values as UTC and normalizes + // offset-bearing values to UTC, so the resulting instance does not depend on the host's + // local timezone. Also accepts non-strict ISO 8601 forms emitted by connectors (e.g. + // "+0000" instead of "+00:00") that element.GetDateTime() would reject. + if (!DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto)) + { + return new ErrorValue(IRContext.NotInSource(targetType), new ExpressionError() + { + Message = $"Date '{strValue}' could not be parsed", + Span = new Syntax.Span(0, 0), + Kind = ErrorKind.InvalidArgument + }); + } - var value = funcParse(dateTime); - return value; + return funcParse(dto.UtcDateTime); } internal static FormulaValue FromJson(JsonElement element, FormulaValueJsonSerializerSettings settings, FormulaValueJsonSerializerWorkingData data, FormulaType formulaType = null) diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/AsType_UO.txt b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/AsType_UO.txt index 8dbfcba47d..8c02c9c7eb 100644 --- a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/AsType_UO.txt +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/AsType_UO.txt @@ -29,11 +29,11 @@ Date(1900,12,31) >> AsType(ParseJSON("""1900-12-31T00:00:00.000Z"""), Date) Date(1900,12,31) ->> AsType(ParseJSON("""1900-12-31T23:59:59.999"""), DateTime) -DateTime(1900,12,31,23,59,59,999) +>> AsType(ParseJSON("""1900-12-31T23:59:59.999"""), DateTime) = DateTimeValue("1900-12-31T23:59:59.999Z") +true ->> AsType(ParseJSON("""1900-12-31"""), DateTime) -DateTime(1900,12,31,0,0,0,0) +>> AsType(ParseJSON("""1900-12-31"""), DateTime) = DateTimeValue("1900-12-31T00:00:00Z") +true >> AsType(ParseJSON("""11:59:59.999"""), Time) Time(11,59,59,999) diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/IsType_UO.txt b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/IsType_UO.txt index c918baed47..c01861000e 100644 --- a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/IsType_UO.txt +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/IsType_UO.txt @@ -88,7 +88,7 @@ false true >> IsType(ParseJSON("""1900-12-31T24:59:59.1002Z"""), DateTime) -false +Error({Kind:ErrorKind.InvalidArgument}) >> IsType(ParseJSON("""24:59:59.12345678"""), Time) false diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/TypedParseJSON.txt b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/TypedParseJSON.txt index e15852dbc1..eddc87eb99 100644 --- a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/TypedParseJSON.txt +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/TypedParseJSON.txt @@ -29,11 +29,11 @@ Date(1984,1,1) >> ParseJSON("""1900-12-31T23:59:59.999""", Date) Date(1900,12,31) ->> ParseJSON("""2008-01-01T12:12:12.100""", DateTime) -DateTime(2008,1,1,12,12,12,100) +>> ParseJSON("""2008-01-01T12:12:12.100""", DateTime) = DateTimeValue("2008-01-01T12:12:12.100Z") +true ->> ParseJSON("""1900-12-31""", DateTime) -DateTime(1900,12,31,0,0,0,0) +>> ParseJSON("""1900-12-31""", DateTime) = DateTimeValue("1900-12-31T00:00:00Z") +true >> ParseJSON("""11:59:59.999""", Time) Time(11,59,59,999) diff --git a/src/tests/Microsoft.PowerFx.Json.Tests.Shared/AsTypeIsTypeParseJSONTests.cs b/src/tests/Microsoft.PowerFx.Json.Tests.Shared/AsTypeIsTypeParseJSONTests.cs index 3d85d62353..91819897be 100644 --- a/src/tests/Microsoft.PowerFx.Json.Tests.Shared/AsTypeIsTypeParseJSONTests.cs +++ b/src/tests/Microsoft.PowerFx.Json.Tests.Shared/AsTypeIsTypeParseJSONTests.cs @@ -49,7 +49,11 @@ public void PrimitivesTest() CheckIsTypeAsTypeParseJSON(engine, "\"17.29\"", "Float", 17.29D); CheckIsTypeAsTypeParseJSON(engine, "\"\"\"HelloWorld\"\"\"", "Text", "HelloWorld"); CheckIsTypeAsTypeParseJSON(engine, "\"\"\"2000-01-01\"\"\"", "Date", new DateTime(2000, 1, 1)); - CheckIsTypeAsTypeParseJSON(engine, "\"\"\"2000-01-01T00:00:01.100\"\"\"", "DateTime", new DateTime(2000, 1, 1, 0, 0, 1, 100)); + + // DateTime strings without an offset are parsed as UTC (matches Power Automate / + // Logic Apps semantics) and then converted to the runner's time zone for storage. + var expectedDateTime = TimeZoneInfo.ConvertTimeFromUtc(new DateTime(2000, 1, 1, 0, 0, 1, 100, DateTimeKind.Utc), TimeZoneInfo.Local); + CheckIsTypeAsTypeParseJSON(engine, "\"\"\"2000-01-01T00:00:01.100\"\"\"", "DateTime", expectedDateTime); CheckIsTypeAsTypeParseJSON(engine, "\"true\"", "Boolean", true); CheckIsTypeAsTypeParseJSON(engine, "\"false\"", "Boolean", false); CheckIsTypeAsTypeParseJSON(engine, "\"1234.56789\"", "Decimal", 1234.56789m); diff --git a/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs b/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs index d6bd56018a..a5e72726ff 100644 --- a/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs +++ b/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs @@ -601,14 +601,16 @@ public void ParseDates_Error() using var doc = JsonDocument.Parse("\"123\""); // not a date var je = doc.RootElement; - Assert.Throws(() => - FormulaValueJSON.ParseDate(je, FormulaType.DateTime, (datetime) => throw new InvalidOperationException($"don't invoke this"))); + var value = FormulaValueJSON.ParseDate(je, FormulaType.DateTime, (datetime) => throw new InvalidOperationException($"don't invoke this")); + + var errorValue = Assert.IsType(value); + Assert.Contains("could not be parsed", errorValue.Errors[0].Message); } [Fact] public void ParseDates_Value() { - using var doc = JsonDocument.Parse("\"2024-10-02T23:13:50.123456\""); // not a date + using var doc = JsonDocument.Parse("\"2024-10-02T23:13:50.123456\""); var je = doc.RootElement; var value = FormulaValueJSON.ParseDate(je, FormulaType.DateTime, (datetime) => FormulaValue.New(datetime)); @@ -617,5 +619,70 @@ public void ParseDates_Value() var dt = dtValue.GetConvertedValue(TimeZoneInfo.Local); Assert.Equal(2024, dt.Year); } + + [Fact] + public void ParseDates_LenientTimezoneOffset() + { + // Jira-style date with timezone offset without colon (+0000 instead of +00:00) + // System.Text.Json's GetDateTime() rejects this, but DateTimeOffset.Parse handles it + using var doc = JsonDocument.Parse("\"2026-03-26T19:18:26.729+0000\""); + var je = doc.RootElement; + + var value = FormulaValueJSON.ParseDate(je, FormulaType.DateTime, (datetime) => FormulaValue.New(datetime)); + + var dtValue = Assert.IsType(value); + var dt = dtValue.GetConvertedValue(TimeZoneInfo.Utc); + Assert.Equal(2026, dt.Year); + Assert.Equal(3, dt.Month); + Assert.Equal(26, dt.Day); + Assert.Equal(19, dt.Hour); + Assert.Equal(18, dt.Minute); + } + + [Fact] + public void ParseDates_LenientTimezoneOffset_DateOnly() + { + using var doc = JsonDocument.Parse("\"2026-03-26T19:18:26.729+0000\""); + var je = doc.RootElement; + + var value = FormulaValueJSON.ParseDate(je, FormulaType.Date, (datetime) => FormulaValue.NewDateOnly(datetime.Date)); + + var dtValue = Assert.IsType(value); + var dt = dtValue.GetConvertedValue(TimeZoneInfo.Utc); + Assert.Equal(2026, dt.Year); + Assert.Equal(3, dt.Month); + Assert.Equal(26, dt.Day); + } + + [Fact] + public void ParseDates_OffsetNormalizedToUtc() + { + // Verify parsing is machine-independent: an input with an explicit offset should + // land on the same UTC instant regardless of the host's local timezone. + // 2024-01-15T10:30:00+05:00 == 2024-01-15T05:30:00Z + using var doc = JsonDocument.Parse("\"2024-01-15T10:30:00+05:00\""); + var je = doc.RootElement; + + var value = FormulaValueJSON.ParseDate(je, FormulaType.DateTime, (datetime) => FormulaValue.New(datetime)); + + var dtValue = Assert.IsType(value); + var dt = dtValue.GetConvertedValue(TimeZoneInfo.Utc); + Assert.Equal(new DateTime(2024, 1, 15, 5, 30, 0, DateTimeKind.Utc), dt); + } + + [Fact] + public void ParseDates_NoOffsetAssumedUtc() + { + // Input with no offset should be treated as UTC (AssumeUniversal), matching + // Power Automate / Logic Apps behavior rather than the host's local timezone. + using var doc = JsonDocument.Parse("\"2024-10-02T23:13:50\""); + var je = doc.RootElement; + + var value = FormulaValueJSON.ParseDate(je, FormulaType.DateTime, (datetime) => FormulaValue.New(datetime)); + + var dtValue = Assert.IsType(value); + var dt = dtValue.GetConvertedValue(TimeZoneInfo.Utc); + Assert.Equal(new DateTime(2024, 10, 2, 23, 13, 50, DateTimeKind.Utc), dt); + } } }