From 40c13100d3fc4f99b4d3db80a4405ada34a43ead Mon Sep 17 00:00:00 2001 From: Eric Buist Date: Thu, 2 Apr 2026 13:03:10 -0400 Subject: [PATCH 1/6] Fix ParseDate to handle non-strict ISO 8601 date formats from connectors ParseDate used element.GetDateTime() which only accepts strict ISO 8601. Connector APIs like Jira return valid dates with timezone offsets without colons (e.g. "+0000" instead of "+00:00") that GetDateTime() rejects. Now falls back to DateTimeOffset.TryParse on FormatException, and returns an ErrorValue instead of throwing if both parsers fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../FormulaValueJSON.cs | 32 ++++++++++++-- .../ParseJSONTests.cs | 42 +++++++++++++++++-- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs index aa705a3af5..33daed3da8 100644 --- a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs +++ b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs @@ -87,17 +87,41 @@ 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(); + DateTime dateTime; + + try + { + dateTime = element.GetDateTime(); + } + catch (FormatException) + { + // element.GetDateTime() uses a strict ISO 8601 parser that rejects valid + // date formats commonly returned by connectors (e.g. timezone offsets + // without a colon like "+0000" instead of "+00:00"). + // Fall back to the more lenient DateTimeOffset.Parse. + if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto)) + { + dateTime = dto.UtcDateTime; + } + else + { + 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; diff --git a/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs b/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs index d6bd56018a..1552c418a0 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,39 @@ 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); + } } } From 597271270142cec003bb1aff5ad645163aa4bf82 Mon Sep 17 00:00:00 2001 From: Eric Buist Date: Mon, 13 Apr 2026 10:28:01 -0400 Subject: [PATCH 2/6] Use AssumeUniversal in ParseDate fallback to match Flow connector behavior Align DateTimeOffset.TryParse fallback with SwaggerTypeFormatExtensions' ConvertToDate/ConvertToDatetime, which use DateTimeStyles.AssumeUniversal so that date strings without timezone info are treated as UTC. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs index 33daed3da8..2e4497621b 100644 --- a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs +++ b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs @@ -108,7 +108,7 @@ internal static FormulaValue ParseDate(JsonElement element, FormulaType targetTy // date formats commonly returned by connectors (e.g. timezone offsets // without a colon like "+0000" instead of "+00:00"). // Fall back to the more lenient DateTimeOffset.Parse. - if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto)) + if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto)) { dateTime = dto.UtcDateTime; } From 1f6146a9d6b0511f554448db8535a8afa61c95fe Mon Sep 17 00:00:00 2001 From: Eric Buist Date: Wed, 22 Apr 2026 15:54:53 -0400 Subject: [PATCH 3/6] Unify ParseDate on DateTimeOffset.TryParse for consistent UTC semantics Replaces the GetDateTime-with-fallback logic in FormulaValueJSON.ParseDate with a single DateTimeOffset.TryParse(AssumeUniversal) call. This removes the two-path inconsistency where strict and lenient inputs could differ in how the stored instant was derived, and aligns no-offset inputs with Power Automate / Logic Apps semantics (treated as UTC rather than literal local wall-clock time). Updates PrimitivesTest to express its expected value as the local-time projection of the UTC instant so it remains machine-stable. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FormulaValueJSON.cs | 37 ++++++------------- .../AsTypeIsTypeParseJSONTests.cs | 6 ++- .../ParseJSONTests.cs | 31 ++++++++++++++++ 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs index 2e4497621b..5350c005ec 100644 --- a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs +++ b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs @@ -96,35 +96,22 @@ internal static FormulaValue ParseDate(JsonElement element, FormulaType targetTy return FormulaValue.NewBlank(targetType); } - DateTime dateTime; - - try - { - dateTime = element.GetDateTime(); - } - catch (FormatException) + // 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 instant 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 | DateTimeStyles.AllowWhiteSpaces, out var dto)) { - // element.GetDateTime() uses a strict ISO 8601 parser that rejects valid - // date formats commonly returned by connectors (e.g. timezone offsets - // without a colon like "+0000" instead of "+00:00"). - // Fall back to the more lenient DateTimeOffset.Parse. - if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto)) - { - dateTime = dto.UtcDateTime; - } - else + return new ErrorValue(IRContext.NotInSource(targetType), new ExpressionError() { - return new ErrorValue(IRContext.NotInSource(targetType), new ExpressionError() - { - Message = $"Date '{strValue}' could not be parsed", - Span = new Syntax.Span(0, 0), - Kind = ErrorKind.InvalidArgument - }); - } + 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.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 1552c418a0..a5e72726ff 100644 --- a/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs +++ b/src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs @@ -653,5 +653,36 @@ public void ParseDates_LenientTimezoneOffset_DateOnly() 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); + } } } From 9e23381b0942fcb80fe01d19d259dddf34970957 Mon Sep 17 00:00:00 2001 From: Eric Buist Date: Mon, 27 Apr 2026 09:24:13 -0400 Subject: [PATCH 4/6] Fix typo and remove the AllowWhitespace for consistency with Power Automate --- src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs index 5350c005ec..0b95d0f2a7 100644 --- a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs +++ b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs @@ -101,7 +101,7 @@ internal static FormulaValue ParseDate(JsonElement element, FormulaType targetTy // offset-bearing values to UTC, so the resulting instant 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 | DateTimeStyles.AllowWhiteSpaces, out var dto)) + if (!DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto)) { return new ErrorValue(IRContext.NotInSource(targetType), new ExpressionError() { From c46752b36f700ec4b97bb1ad9406b24e2664fd36 Mon Sep 17 00:00:00 2001 From: Eric Buist Date: Wed, 29 Apr 2026 08:46:10 -0400 Subject: [PATCH 5/6] Fix typo --- src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs index 0b95d0f2a7..9b33d37450 100644 --- a/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs +++ b/src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs @@ -98,7 +98,7 @@ internal static FormulaValue ParseDate(JsonElement element, FormulaType targetTy // 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 instant does not depend on the host's + // 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)) From 11cec69d7d79b2a52745bf596e6fcdad7178c88e Mon Sep 17 00:00:00 2001 From: Eric Buist Date: Mon, 4 May 2026 15:44:26 -0400 Subject: [PATCH 6/6] Update ParseJSON DateTime tests for Power Automate UTC semantics Offset-less DateTime strings are now parsed as UTC (AssumeUniversal), so literal DateTime expectations are timezone-dependent. Rewrite the four affected cases as comparisons against DateTimeValue("...Z") so the assertion holds in any runner timezone, and update the IsType case for the explicit invalid-hour input to expect ErrorKind.InvalidArgument now that ParseDate returns a typed error instead of throwing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ExpressionTestCases/AsType_UO.txt | 8 ++++---- .../ExpressionTestCases/IsType_UO.txt | 2 +- .../ExpressionTestCases/TypedParseJSON.txt | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) 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)