Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions src/libraries/Microsoft.PowerFx.Json/FormulaValueJSON.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTime, FormulaValue> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
73 changes: 70 additions & 3 deletions src/tests/Microsoft.PowerFx.Json.Tests.Shared/ParseJSONTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -601,14 +601,16 @@ public void ParseDates_Error()
using var doc = JsonDocument.Parse("\"123\""); // not a date
var je = doc.RootElement;

Assert.Throws<FormatException>(() =>
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<ErrorValue>(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));
Expand All @@ -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<DateTimeValue>(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<DateValue>(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<DateTimeValue>(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<DateTimeValue>(value);
var dt = dtValue.GetConvertedValue(TimeZoneInfo.Utc);
Assert.Equal(new DateTime(2024, 10, 2, 23, 13, 50, DateTimeKind.Utc), dt);
}
}
}