From efd9fc5230e849ccce5bcbbc7eb071f1667bff78 Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Tue, 18 Jul 2023 17:43:51 +0200 Subject: [PATCH 1/4] Use a cached value to improve performance of the Date type. --- src/Hl7.Fhir.Base/ElementModel/Types/Date.cs | 93 ++++---- .../ElementModel/Types/DateTime.cs | 209 ++++++++++-------- .../FhirPath/ElementNavFhirExtensions.cs | 4 +- src/Hl7.Fhir.Base/Model/Date.cs | 101 +++++++-- src/Hl7.Fhir.Base/Model/FhirDateTime.cs | 13 +- .../Model/DateTests.cs | 152 +++++++++++++ .../Model/DateTimeTests.cs | 2 +- .../ElementModel/DateTest.cs | 29 ++- .../ElementModel/DateTimeTest.cs | 5 +- 9 files changed, 435 insertions(+), 173 deletions(-) create mode 100644 src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs diff --git a/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs b/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs index 9012c5d11a..b497ca52e4 100644 --- a/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs +++ b/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs @@ -17,36 +17,25 @@ namespace Hl7.Fhir.ElementModel.Types { public class Date : Any, IComparable, ICqlEquatable, ICqlOrderable, ICqlConvertible { - private Date(string original, DateTimeOffset parsedValue, DateTimePrecision precision, bool hasOffset) + private Date(DateTimeOffset value, DateTimePrecision precision, bool hasOffset) { - _original = original; - _parsedValue = parsedValue; + if (precision > DateTimePrecision.Day) throw new ArgumentException($"Invalid precision {precision}, cannot be more than {nameof(DateTimePrecision.Day)}.", nameof(precision)); + + _value = value; Precision = precision; HasOffset = hasOffset; } public static Date Parse(string representation) => - TryParse(representation, out var result) ? result : throw new FormatException($"String '{representation}' was not recognized as a valid date."); + TryParse(representation, out var result) ? result! : throw new FormatException($"String '{representation}' was not recognized as a valid date."); public static bool TryParse(string representation, out Date value) => tryParse(representation, out value); public static Date FromDateTimeOffset(DateTimeOffset dto, DateTimePrecision prec = DateTimePrecision.Day, - bool includeOffset = false) - { - string formatString = prec switch - { - DateTimePrecision.Year => "yyyy", - DateTimePrecision.Month => "yyyy-MM", - _ => "yyyy-MM-dd", - }; - if (includeOffset) formatString += "K"; - - var representation = dto.ToString(formatString); - return Parse(representation); - } + bool includeOffset = false) => new(dto, prec, includeOffset); - public DateTime ToDateTime() => new(_parsedValue, Precision, HasOffset); + public DateTime ToDateTime() => DateTime.FromDateTimeOffset(_value, Precision, HasOffset); public static Date Today(bool includeOffset = false) => FromDateTimeOffset(DateTimeOffset.Now, includeOffset: includeOffset); @@ -55,27 +44,32 @@ public static Date FromDateTimeOffset(DateTimeOffset dto, DateTimePrecision prec /// public DateTimePrecision Precision { get; private set; } - public int? Years => Precision >= DateTimePrecision.Year ? _parsedValue.Year : (int?)null; - public int? Months => Precision >= DateTimePrecision.Month ? _parsedValue.Month : (int?)null; - public int? Days => Precision >= DateTimePrecision.Day ? _parsedValue.Day : (int?)null; + public int? Years => Precision >= DateTimePrecision.Year ? _value.Year : (int?)null; + public int? Months => Precision >= DateTimePrecision.Month ? _value.Month : (int?)null; + public int? Days => Precision >= DateTimePrecision.Day ? _value.Day : (int?)null; /// /// The span of time ahead/behind UTC /// - public TimeSpan? Offset => HasOffset ? _parsedValue.Offset : (TimeSpan?)null; - - private readonly string _original; - private readonly DateTimeOffset _parsedValue; + public TimeSpan? Offset => HasOffset ? _value.Offset : null; /// /// Whether the time specifies an offset to UTC /// public bool HasOffset { get; private set; } - private static readonly string DATEFORMAT = - $"(?[0-9]{{4}}) ((?-[0-9][0-9]) ((?-[0-9][0-9]) )?)? {Time.OFFSETFORMAT}?"; - public static readonly Regex PARTIALDATEREGEX = new("^" + DATEFORMAT + "$", - RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled | RegexOptions.ExplicitCapture); + /// + /// If this instance was constructed using Parse(), this is the original + /// raw input to the parse. Used to guarantee roundtrippability. + /// + private string? _originalParsedString { get; init; } + + private readonly DateTimeOffset _value; + + /// + /// Converts the date to a full DateTimeOffset instance. + /// + public DateTimeOffset ToDateTimeOffset() => ToDateTimeOffset(0, 0, 0, TimeSpan.Zero); /// /// Converts the date to a full DateTimeOffset instance. @@ -98,8 +92,13 @@ public DateTimeOffset ToDateTimeOffset(int hours, int minutes, int seconds, Time /// Offset used when the datetime does not specify one. /// public DateTimeOffset ToDateTimeOffset(int hours, int minutes, int seconds, int milliseconds, TimeSpan defaultOffset) => - new(_parsedValue.Year, _parsedValue.Month, _parsedValue.Day, hours, minutes, seconds, milliseconds, - HasOffset ? _parsedValue.Offset : defaultOffset); + new(_value.Year, _value.Month, _value.Day, hours, minutes, seconds, milliseconds, + HasOffset ? _value.Offset : defaultOffset); + + private static readonly string DATEFORMAT = + $"(?[0-9]{{4}}) ((?-[0-9][0-9]) ((?-[0-9][0-9]) )?)? {Time.OFFSETFORMAT}?"; + public static readonly Regex PARTIALDATEREGEX = new("^" + DATEFORMAT + "$", + RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled | RegexOptions.ExplicitCapture); /// /// Converts the date to a full DateTimeOffset instance. @@ -112,7 +111,7 @@ private static bool tryParse(string representation, out Date value) var matches = PARTIALDATEREGEX.Match(representation); if (!matches.Success) { - value = new Date(representation, default, default, default); + value = new Date(default, default, default); return false; } @@ -133,7 +132,11 @@ private static bool tryParse(string representation, out Date value) (offset.Success ? offset.Value : "Z"); var success = DateTimeOffset.TryParse(parseableDT, out var parsedValue); - value = new Date(representation, parsedValue, prec, offset.Success); + value = new Date(parsedValue, prec, offset.Success) + { + _originalParsedString = representation + }; + return success; } @@ -149,21 +152,21 @@ private static bool tryParse(string representation, out Date value) var dto = addValue.Unit switch { // we can ignore precision, as the precision will "trim" it anyway, and if we add 13 months, then the year can tick over nicely - "years" or "year" => dateValue._parsedValue.AddYears((int)addValue.Value), + "years" or "year" => dateValue._value.AddYears((int)addValue.Value), "month" or "months" => dateValue.Precision == DateTimePrecision.Year - ? dateValue._parsedValue.AddYears((int)(addValue.Value / 12)) - : dateValue._parsedValue.AddMonths((int)addValue.Value), + ? dateValue._value.AddYears((int)(addValue.Value / 12)) + : dateValue._value.AddMonths((int)addValue.Value), "week" or "weeks" or "wk" => dateValue.Precision switch { - DateTimePrecision.Year => dateValue._parsedValue.AddYears((int)(addValue.Value / 52)), - DateTimePrecision.Month => dateValue._parsedValue.AddMonths((int)(addValue.Value * 7 / 30)), - _ => dateValue._parsedValue.AddDays(((int)addValue.Value) * 7) + DateTimePrecision.Year => dateValue._value.AddYears((int)(addValue.Value / 52)), + DateTimePrecision.Month => dateValue._value.AddMonths((int)(addValue.Value * 7 / 30)), + _ => dateValue._value.AddDays(((int)addValue.Value) * 7) }, "day" or "days" or "d" => dateValue.Precision switch { - DateTimePrecision.Year => dateValue._parsedValue.AddYears((int)(addValue.Value / 365)), - DateTimePrecision.Month => dateValue._parsedValue.AddMonths((int)(addValue.Value / 30)), - _ => dateValue._parsedValue.AddDays((int)addValue.Value) + DateTimePrecision.Year => dateValue._value.AddYears((int)(addValue.Value / 365)), + DateTimePrecision.Month => dateValue._value.AddMonths((int)(addValue.Value / 30)), + _ => dateValue._value.AddDays((int)addValue.Value) }, _ => throw new ArgumentException($"'{addValue.Unit}' is not a valid time-valued unit", nameof(addValue)), }; @@ -210,7 +213,7 @@ public Result TryCompareTo(Any other) return other switch { null => 1, - Date p => DateTime.CompareDateTimeParts(_parsedValue, Precision, HasOffset, p._parsedValue, p.Precision, p.HasOffset), + Date p => DateTime.CompareDateTimeParts(_value, Precision, HasOffset, p._value, p.Precision, p.HasOffset), _ => throw NotSameTypeComparison(this, other) }; } @@ -221,8 +224,8 @@ public Result TryCompareTo(Any other) public static bool operator >=(Date a, Date b) => a.CompareTo(b) >= 0; - public override int GetHashCode() => _original.GetHashCode(); - public override string ToString() => _original; + public override int GetHashCode() => _value.GetHashCode(); + public override string ToString() => _originalParsedString is not null ? _originalParsedString : DateTime.ToStringWithPrecision(_value, Precision, HasOffset); public static implicit operator DateTime(Date pd) => pd.ToDateTime(); public static explicit operator Date(DateTimeOffset dto) => FromDateTimeOffset(dto); diff --git a/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs b/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs index b3191dd465..b68c1eee2f 100644 --- a/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs +++ b/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs @@ -17,28 +17,44 @@ namespace Hl7.Fhir.ElementModel.Types { public class DateTime : Any, IComparable, ICqlEquatable, ICqlOrderable, ICqlConvertible { - internal DateTime(DateTimeOffset value, DateTimePrecision precision, bool hasOffset) + private DateTime(DateTimeOffset value, DateTimePrecision precision, bool includeOffset) { - _value = value; + _value = RoundToPrecision(value, precision, includeOffset); Precision = precision; - HasOffset = hasOffset; + HasOffset = includeOffset; } public static DateTime Parse(string representation) => - TryParse(representation, out var result) ? result : throw new FormatException($"String '{representation}' was not recognized as a valid datetime."); + TryParse(representation, out var result) ? result! : throw new FormatException($"String '{representation}' was not recognized as a valid datetime."); public static bool TryParse(string representation, out DateTime value) => tryParse(representation, out value); - public static string FormatDateTimeOffset(DateTimeOffset dto) => dto.ToString(FMT_FULL); - - public static DateTime FromDateTimeOffset(DateTimeOffset dto) => new(dto, DateTimePrecision.Fraction, hasOffset: true); + /// + /// Rounds the contents of a to the given precision, unused precision if filled out + /// as midnight, the first of january, GMT. + /// + /// The to round. + /// The precision to round down to. + /// Whether to use the timezone specified, or round it to . + internal static DateTimeOffset RoundToPrecision(DateTimeOffset source, DateTimePrecision precision, bool withOffset) => precision switch + { + DateTimePrecision.Year => new DateTimeOffset(source.Year, 1, 1, 0, 0, 0, withOffset ? source.Offset : TimeSpan.Zero), + DateTimePrecision.Month => new DateTimeOffset(source.Year, source.Month, 1, 0, 0, 0, withOffset ? source.Offset : TimeSpan.Zero), + DateTimePrecision.Day => new DateTimeOffset(source.Year, source.Month, source.Day, 0, 0, 0, withOffset ? source.Offset : TimeSpan.Zero), + DateTimePrecision.Hour => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, 0, 0, withOffset ? source.Offset : TimeSpan.Zero), + DateTimePrecision.Minute => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, source.Minute, 0, withOffset ? source.Offset : TimeSpan.Zero), + DateTimePrecision.Second => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, source.Minute, source.Second, withOffset ? source.Offset : TimeSpan.Zero), + _ => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, source.Minute, source.Second, source.Millisecond, withOffset ? source.Offset : TimeSpan.Zero), + }; + + public static DateTime FromDateTimeOffset(DateTimeOffset dto, DateTimePrecision prec = DateTimePrecision.Fraction, bool includeOffset = true) => + new(dto, prec, includeOffset); public static DateTime Now() => FromDateTimeOffset(DateTimeOffset.Now); - public static DateTime Today() => new(DateTimeOffset.Now, DateTimePrecision.Day, hasOffset: true); + public static DateTime Today(bool includeOffset = true) => new(DateTimeOffset.Now, DateTimePrecision.Day, includeOffset); - public Date TruncateToDate() => Date.FromDateTimeOffset( - ToDateTimeOffset(_value.Offset), Precision, includeOffset: HasOffset); + public Date TruncateToDate() => Date.FromDateTimeOffset(_value, Precision > DateTimePrecision.Day ? DateTimePrecision.Day : Precision, HasOffset); public int? Years => Precision >= DateTimePrecision.Year ? _value.Year : null; public int? Months => Precision >= DateTimePrecision.Month ? _value.Month : null; @@ -48,80 +64,11 @@ public Date TruncateToDate() => Date.FromDateTimeOffset( public int? Seconds => Precision >= DateTimePrecision.Second ? _value.Second : null; public int? Millis => Precision >= DateTimePrecision.Fraction ? _value.Millisecond : null; - public static DateTime operator +(DateTime dateTimeValue, Quantity addValue) - { - if (dateTimeValue is null) throw new ArgumentNullException(nameof(dateTimeValue)); - if (addValue is null) throw new ArgumentNullException(nameof(addValue)); - - // Based on the discussion on equality/comparisons here: - // https://chat.fhir.org/#narrow/stream/179266-fhirpath/topic/Date.2FTime.20comparison.20vs.20equality - // We have also allowed addition to use the definitve UCUM units of 'wk', 'd', 'h', 'min' as if they are a calendar unit of - // 'week'/'day'/'hour'/'minute' respectively. - var dto = addValue.Unit switch - { - // we can ignore precision, as the precision will "trim" it anyway, and if we add 13 months, then the year can tick over nicely - "years" or "year" => dateTimeValue._value.AddYears((int)addValue.Value), - "month" or "months" => dateTimeValue.Precision == DateTimePrecision.Year - ? dateTimeValue._value.AddYears((int)(addValue.Value / 12)) - : dateTimeValue._value.AddMonths((int)addValue.Value), - "week" or "weeks" or "wk" => dateTimeValue.Precision switch - { - DateTimePrecision.Year => dateTimeValue._value.AddYears((int)(addValue.Value / 52)), - DateTimePrecision.Month => dateTimeValue._value.AddMonths((int)(addValue.Value * 7 / 30)), - _ => dateTimeValue._value.AddDays(((int)addValue.Value) * 7) - }, - "day" or "days" or "d" => dateTimeValue.Precision switch - { - DateTimePrecision.Year => dateTimeValue._value.AddYears((int)(addValue.Value / 365)), - DateTimePrecision.Month => dateTimeValue._value.AddMonths((int)(addValue.Value / 30)), - _ => dateTimeValue._value.AddDays((int)addValue.Value) - }, - - // NOT ignoring precision on time based stuff if there is no time component - // if no time component, don't modify result - "hour" or "hours" or "h" => dateTimeValue.Precision > DateTimePrecision.Day - ? dateTimeValue._value.AddHours((double)addValue.Value) - : dateTimeValue._value, - "minute" or "minutes" or "min" => dateTimeValue.Precision > DateTimePrecision.Day - ? dateTimeValue._value.AddMinutes((double)addValue.Value) - : dateTimeValue._value, - "s" or "second" or "seconds" => dateTimeValue.Precision > DateTimePrecision.Day - ? dateTimeValue._value.AddSeconds((double)addValue.Value) - : dateTimeValue._value, - "ms" or "millisecond" or "milliseconds" => dateTimeValue.Precision > DateTimePrecision.Day - ? dateTimeValue._value.AddMilliseconds((double)addValue.Value) - : dateTimeValue._value, - _ => throw new ArgumentException($"'{addValue.Unit}' is not a valid time-valued unit", nameof(addValue)), - }; - - var resultRepresentation = dto.ToString(FMT_FULL); - var originalRepresentation = dateTimeValue.ToString(); - - if (resultRepresentation.Length > originalRepresentation.Length) - { - // need to trim appropriately. - if (dateTimeValue.Precision <= DateTimePrecision.Minute) - resultRepresentation = resultRepresentation.Substring(0, originalRepresentation.Length); - else - { - if (!dateTimeValue.HasOffset) - { - // trim the offset from it - resultRepresentation = dto.ToString("yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"); - } - } - } - - return Parse(resultRepresentation); - } - /// /// The span of time ahead/behind UTC /// public TimeSpan? Offset => HasOffset ? _value.Offset : null; - private readonly DateTimeOffset _value; - /// /// The precision of the date and time available. /// @@ -132,11 +79,13 @@ public Date TruncateToDate() => Date.FromDateTimeOffset( /// public bool HasOffset { get; private set; } - private static readonly string DATETIMEFORMAT = - $"(?[0-9]{{4}}) ((?-[0-9][0-9]) ((?-[0-9][0-9]) (T{Time.TIMEFORMAT})?)?)? {Time.OFFSETFORMAT}?"; - private static readonly Regex DATETIMEREGEX = - new("^" + DATETIMEFORMAT + "$", - RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled | RegexOptions.ExplicitCapture); + /// + /// If this instance was constructed using Parse(), this is the original + /// raw input to the parse. Used to guarantee roundtrippability. + /// + private string? _originalParsedString { get; init; } + + private readonly DateTimeOffset _value; /// /// Converts the datetime to a full DateTimeOffset instance. @@ -154,6 +103,12 @@ public DateTimeOffset ToDateTimeOffset(TimeSpan defaultOffset) => public const string FMT_FULL = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; + private static readonly string DATETIMEFORMAT = + $"(?[0-9]{{4}}) ((?-[0-9][0-9]) ((?-[0-9][0-9]) (T{Time.TIMEFORMAT})?)?)? {Time.OFFSETFORMAT}?"; + private static readonly Regex DATETIMEREGEX = + new("^" + DATETIMEFORMAT + "$", + RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled | RegexOptions.ExplicitCapture); + private static bool tryParse(string representation, out DateTime value) { if (representation is null) throw new ArgumentNullException(nameof(representation)); @@ -195,17 +150,78 @@ private static bool tryParse(string representation, out DateTime value) var success = DateTimeOffset.TryParse(parseableDT, out var parsedValue); value = new DateTime(parsedValue, prec, offset.Success) { - originalParsedString = representation + _originalParsedString = representation }; return success; } - /// - /// If this instance was constructed using Parse(), this is the original - /// raw input to the parse. Used to guarantee roundtrippability. - /// - private string? originalParsedString { get; init; } + public static DateTime operator +(DateTime dateTimeValue, Quantity addValue) + { + if (dateTimeValue is null) throw new ArgumentNullException(nameof(dateTimeValue)); + if (addValue is null) throw new ArgumentNullException(nameof(addValue)); + + // Based on the discussion on equality/comparisons here: + // https://chat.fhir.org/#narrow/stream/179266-fhirpath/topic/Date.2FTime.20comparison.20vs.20equality + // We have also allowed addition to use the definitve UCUM units of 'wk', 'd', 'h', 'min' as if they are a calendar unit of + // 'week'/'day'/'hour'/'minute' respectively. + var dto = addValue.Unit switch + { + // we can ignore precision, as the precision will "trim" it anyway, and if we add 13 months, then the year can tick over nicely + "years" or "year" => dateTimeValue._value.AddYears((int)addValue.Value), + "month" or "months" => dateTimeValue.Precision == DateTimePrecision.Year + ? dateTimeValue._value.AddYears((int)(addValue.Value / 12)) + : dateTimeValue._value.AddMonths((int)addValue.Value), + "week" or "weeks" or "wk" => dateTimeValue.Precision switch + { + DateTimePrecision.Year => dateTimeValue._value.AddYears((int)(addValue.Value / 52)), + DateTimePrecision.Month => dateTimeValue._value.AddMonths((int)(addValue.Value * 7 / 30)), + _ => dateTimeValue._value.AddDays(((int)addValue.Value) * 7) + }, + "day" or "days" or "d" => dateTimeValue.Precision switch + { + DateTimePrecision.Year => dateTimeValue._value.AddYears((int)(addValue.Value / 365)), + DateTimePrecision.Month => dateTimeValue._value.AddMonths((int)(addValue.Value / 30)), + _ => dateTimeValue._value.AddDays((int)addValue.Value) + }, + + // NOT ignoring precision on time based stuff if there is no time component + // if no time component, don't modify result + "hour" or "hours" or "h" => dateTimeValue.Precision > DateTimePrecision.Day + ? dateTimeValue._value.AddHours((double)addValue.Value) + : dateTimeValue._value, + "minute" or "minutes" or "min" => dateTimeValue.Precision > DateTimePrecision.Day + ? dateTimeValue._value.AddMinutes((double)addValue.Value) + : dateTimeValue._value, + "s" or "second" or "seconds" => dateTimeValue.Precision > DateTimePrecision.Day + ? dateTimeValue._value.AddSeconds((double)addValue.Value) + : dateTimeValue._value, + "ms" or "millisecond" or "milliseconds" => dateTimeValue.Precision > DateTimePrecision.Day + ? dateTimeValue._value.AddMilliseconds((double)addValue.Value) + : dateTimeValue._value, + _ => throw new ArgumentException($"'{addValue.Unit}' is not a valid time-valued unit", nameof(addValue)), + }; + + var resultRepresentation = dto.ToString(FMT_FULL); + var originalRepresentation = dateTimeValue.ToString(); + + if (resultRepresentation.Length > originalRepresentation.Length) + { + // need to trim appropriately. + if (dateTimeValue.Precision <= DateTimePrecision.Minute) + resultRepresentation = resultRepresentation.Substring(0, originalRepresentation.Length); + else + { + if (!dateTimeValue.HasOffset) + { + // trim the offset from it + resultRepresentation = dto.ToString("yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"); + } + } + } + + return Parse(resultRepresentation); + } /// /// Compare two datetimes based on CQL equality rules @@ -307,12 +323,12 @@ internal static Result CompareDateTimeParts(DateTimeOffset l, DateTimePreci public override int GetHashCode() => _value.GetHashCode(); - public override string ToString() - { - if (originalParsedString is not null) return originalParsedString; + public override string ToString() => _originalParsedString is not null ? _originalParsedString : ToStringWithPrecision(_value, Precision, HasOffset); + internal static string ToStringWithPrecision(DateTimeOffset dto, DateTimePrecision prec, bool includeOffset) + { // "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; - var length = Precision switch + var length = prec switch { DateTimePrecision.Year => 4, DateTimePrecision.Month => 7, @@ -325,8 +341,8 @@ public override string ToString() }; var format = FMT_FULL.Substring(0, length); - if (HasOffset) format += 'K'; - return _value.ToString(format); + if (includeOffset) format += 'K'; + return dto.ToString(format); } public static explicit operator DateTime(DateTimeOffset dto) => FromDateTimeOffset(dto); @@ -357,5 +373,6 @@ public override string ToString() Result ICqlConvertible.TryConvertToCode() => CannotCastTo(this); Result ICqlConvertible.TryConvertToConcept() => CannotCastTo(this); + public static string FormatDateTimeOffset(DateTimeOffset dto) => dto.ToString(FMT_FULL); } } diff --git a/src/Hl7.Fhir.Base/FhirPath/ElementNavFhirExtensions.cs b/src/Hl7.Fhir.Base/FhirPath/ElementNavFhirExtensions.cs index 818e432695..e66eb4fe7b 100644 --- a/src/Hl7.Fhir.Base/FhirPath/ElementNavFhirExtensions.cs +++ b/src/Hl7.Fhir.Base/FhirPath/ElementNavFhirExtensions.cs @@ -215,7 +215,7 @@ internal static P.Any BoundaryDateTime(P.DateTime dt, long? precision, int month return (dtPrecision <= P.DateTimePrecision.Day) ? P.Date.FromDateTimeOffset(dto, dtPrecision, dt.HasOffset) : - new P.DateTime(dto, dtPrecision, dt.HasOffset); + P.DateTime.FromDateTimeOffset(dto, dtPrecision, dt.HasOffset); } internal static P.Time BoundaryTime(P.Time time, long? precision, int minutes, int seconds, int milliseconds) @@ -273,7 +273,7 @@ internal static P.Time BoundaryTime(P.Time time, long? precision, int minutes, i internal static bool? MemberOf(ITypedElement input, string valueset, EvaluationContext ctx) { var service = (ctx is FhirEvaluationContext fctx ? fctx.TerminologyService : null) - ?? throw new ArgumentNullException("The 'memberOf' function cannot be executed because the FhirEvaluationContext does not include a TerminologyService."); + ?? throw new ArgumentNullException(nameof(ctx), "The 'memberOf' function cannot be executed because the FhirEvaluationContext does not include a TerminologyService."); ValidateCodeParameters? inParams = new ValidateCodeParameters() .WithValueSet(valueset); diff --git a/src/Hl7.Fhir.Base/Model/Date.cs b/src/Hl7.Fhir.Base/Model/Date.cs index 0febdea184..e93ad937ad 100644 --- a/src/Hl7.Fhir.Base/Model/Date.cs +++ b/src/Hl7.Fhir.Base/Model/Date.cs @@ -28,8 +28,8 @@ POSSIBILITY OF SUCH DAMAGE. */ -using Hl7.Fhir.Serialization; using System; +using P = Hl7.Fhir.ElementModel.Types; #nullable enable @@ -38,41 +38,114 @@ namespace Hl7.Fhir.Model public partial class Date { public Date(int year, int month, int day) - : this(string.Format(FhirDateTime.FMT_YEARMONTHDAY, year, month, day)) + : this(string.Format(System.Globalization.CultureInfo.InvariantCulture, FhirDateTime.FMT_YEARMONTHDAY, year, month, day)) { } public Date(int year, int month) - : this(string.Format(FhirDateTime.FMT_YEARMONTH, year, month)) + : this(string.Format(System.Globalization.CultureInfo.InvariantCulture, FhirDateTime.FMT_YEARMONTH, year, month)) { } - public Date(int year) : this(string.Format(FhirDateTime.FMT_YEAR, year)) + public Date(int year) : this(string.Format(System.Globalization.CultureInfo.InvariantCulture, FhirDateTime.FMT_YEAR, year)) { } + public static Date FromDateTimeOffset(DateTimeOffset date) => new(date.Year, date.Month, date.Day); + /// - /// Gets the current date in the local timezone + /// Gets the current date in the local timezone. /// - /// Gets the current date in the local timezone - public static Date Today() => new(DateTimeOffset.Now.ToString("yyyy-MM-dd")); + public static Date Today() => FromDateTimeOffset(DateTimeOffset.Now); /// - /// Gets the current date in the timezone UTC + /// Gets the current date in UTC. /// - /// Gets the current date in the timezone UTC - public static Date UtcToday() => new(DateTimeOffset.UtcNow.ToString("yyyy-MM-dd")); + public static Date UtcToday() => FromDateTimeOffset(DateTimeOffset.UtcNow); + + [NonSerialized] // To prevent binary serialization from serializing this field + private P.Date? _parsedValue = null; + + private static readonly P.Date INVALID_VALUE = P.Date.Today(); /// - /// Converts this instance of a (partial) date into a .NET . + /// Converts a Fhir Date to a . /// - public DateTimeOffset? ToDateTimeOffset() => - Value == null ? null : PrimitiveTypeConverter.ConvertTo(Value); + /// true if the Fhir Date contains a valid date string, false otherwise. + public bool TryToDate(out P.Date? date) + { + if (_parsedValue is null) + { + if (Value is not null && !(P.Date.TryParse(Value, out _parsedValue) && !_parsedValue!.HasOffset)) + _parsedValue = INVALID_VALUE; + } + + if (hasInvalidParsedValue()) + { + date = null; + return false; + } + else + { + date = _parsedValue; + return true; + } + + bool hasInvalidParsedValue() => ReferenceEquals(_parsedValue, INVALID_VALUE); + } + + /// + /// Converts a Fhir Date to a . + /// + /// The Date, or null if the is null. + /// Thrown when the Value does not contain a valid FHIR Date. + public P.Date? ToDate() => TryToDate(out var dt) ? dt : throw new FormatException($"String '{Value}' was not recognized as a valid date."); + + protected override void OnObjectValueChanged() + { + _parsedValue = null; + base.OnObjectValueChanged(); + } + + /// + /// Converts this Fhir Fhir Date to a . + /// + /// A DateTimeOffset filled out to midnight, january 1 (UTC) in case of a partial date. + public DateTimeOffset ToDateTimeOffset() + { + if (Value == null) throw new InvalidOperationException("Date's value is null"); + + // ToDateTimeOffset() will convert partial date/times by filling out to midnight/january 1 UTC + // When there's no timezone, the UTC is assumed + if (!TryToDate(out var dt)) + throw new FormatException($"String '{Value}' was not recognized as a valid datetime."); + + // Since Value is not null and the parsed value is valid, dto will not be null + return dt!.ToDateTimeOffset(); + } + + /// + /// Convert this Fhir Date to a . + /// + /// True if the value of the Fhir Date is not null and can be parsed as a DateTimeOffset, false otherwise. + public bool TryToDateTimeOffset(out DateTimeOffset dto) + { + if (Value is not null && TryToDate(out var dt)) + { + dto = dt!.ToDateTimeOffset(); + return true; + } + else + { + dto = default; + return false; + } + } /// /// Checks whether the given literal is correctly formatted. /// - public static bool IsValidValue(string value) => ElementModel.Types.Date.TryParse(value, out var parsed) && !parsed.HasOffset; + public static bool IsValidValue(string value) => P.Date.TryParse(value, out var parsed) && !parsed!.HasOffset; } } diff --git a/src/Hl7.Fhir.Base/Model/FhirDateTime.cs b/src/Hl7.Fhir.Base/Model/FhirDateTime.cs index ed11b32a54..98bb231e8e 100644 --- a/src/Hl7.Fhir.Base/Model/FhirDateTime.cs +++ b/src/Hl7.Fhir.Base/Model/FhirDateTime.cs @@ -32,7 +32,6 @@ POSSIBILITY OF SUCH DAMAGE. using Hl7.Fhir.Serialization; using System; -using System.Text.RegularExpressions; using P = Hl7.Fhir.ElementModel.Types; namespace Hl7.Fhir.Model @@ -59,15 +58,6 @@ public partial class FhirDateTime /// public const string FMT_YEARMONTHDAY = "{0:D4}-{1:D2}-{2:D2}"; - private static readonly string DATEFORMAT = - $"(?[0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000) (?-(0[1-9]|1[0-2]) (?-(0[1-9]|[1-2][0-9]|3[0-1])"; - private static readonly string TIMEFORMAT = - $"(T(?[01][0-9]|2[0-3]) (?:[0-5][0-9]) (?:[0-5][0-9]|60)(?\\.[0-9]+) ?(?Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?"; - - private static readonly Regex DATETIMEREGEX = - new("^" + DATEFORMAT + TIMEFORMAT + "$", - RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled | RegexOptions.ExplicitCapture); - public FhirDateTime(DateTimeOffset dt) : this(PrimitiveTypeConverter.ConvertTo(dt)) { } @@ -129,6 +119,7 @@ public bool TryToDateTime(out P.DateTime? dateTime) /// /// Converts a FhirDateTime to a . /// + /// The DateTime, or null if the is null. /// Thrown when the Value does not contain a valid FHIR DateTime. public P.DateTime? ToDateTime() => TryToDateTime(out var dt) ? dt : throw new FormatException($"String '{Value}' was not recognized as a valid datetime."); @@ -150,7 +141,7 @@ protected override void OnObjectValueChanged() /// effect on this, this merely converts the given Fhir datetime to the desired timezone public DateTimeOffset ToDateTimeOffset(TimeSpan zone) { - if (this.Value == null) throw new InvalidOperationException("FhirDateTime's value is null"); + if (Value == null) throw new InvalidOperationException("FhirDateTime's value is null"); // ToDateTimeOffset() will convert partial date/times by filling out to midnight/january 1 UTC // When there's no timezone, the UTC is assumed diff --git a/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs new file mode 100644 index 0000000000..b0cfd468ca --- /dev/null +++ b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2014, Firely (info@fire.ly) and contributors + * See the file CONTRIBUTORS for details. + * + * This file is licensed under the BSD 3-Clause license + * available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE + */ + +using FluentAssertions; +using Hl7.Fhir.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Diagnostics; + +namespace Hl7.Fhir.Tests.Model +{ + [TestClass] + public class DateTests + { + [TestMethod] + public void DateHandling() + { + var dt = new Date(2010, 1, 1); + Assert.AreEqual("2010-01-01", dt.Value); + + var dt2 = new Date(1972, 11, 30); + dt2.Value.Should().Be("1972-11-30"); + + var dtNoDay = new Date(2014, 12); + dtNoDay.Value.Should().Be("2014-12"); + + var stamp = new DateTimeOffset(1972, 11, 30, 15, 10, 0, TimeSpan.Zero); + dt = Date.FromDateTimeOffset(stamp); + dt.Value.Should().Be("1972-11-30"); + } + + [TestMethod] + public void TryToDateTimeOffset() + { + var fdt = new Date(2021, 3, 18); + fdt.TryToDateTimeOffset(out var dto1).Should().BeTrue(); + Assert.AreEqual("2021-03-18T00:00:00.0000000+00:00", dto1.ToString("o")); + + fdt = new Date(2021, 32, 18); + fdt.TryToDateTimeOffset(out var _).Should().BeFalse(); + + fdt = new Date("2021-03-18+01:00"); + fdt.TryToDateTimeOffset(out var _).Should().BeFalse(); + + fdt = new Date("2021-32-18"); + fdt.TryToDateTimeOffset(out var _).Should().BeFalse(); + } + + [TestMethod] + public void TodayTests() + { + var todayLocal = Date.Today(); + Assert.AreEqual(DateTimeOffset.Now.ToString("yyy-MM-dd"), todayLocal.Value); + + var todayUtc = Date.UtcToday(); + Assert.AreEqual(DateTimeOffset.UtcNow.ToString("yyy-MM-dd"), todayUtc.Value); + } + + [TestMethod] + public void UpdatesCachedValue() + { + var dft = new Date(2023, 07, 11); + dft.TryToDateTimeOffset(out var dto).Should().BeTrue(); + dft.TryToDateTimeOffset(out var dto2).Should().BeTrue(); + dto.Equals(dto2).Should().BeTrue(); + + dft.Value = "2023-07-11"; + dft.TryToDateTimeOffset(out dto).Should().BeTrue(); + dto.Day.Should().Be(11); + dft.TryToDateTimeOffset(out dto2).Should().BeTrue(); + dto.Equals(dto2).Should().BeTrue(); + + dft.ObjectValue = "2023-07-11"; + dft.TryToDateTimeOffset(out dto).Should().BeTrue(); + dto.Month.Should().Be(7); + dft.TryToDateTimeOffset(out dto2).Should().BeTrue(); + dto.Equals(dto2).Should().BeTrue(); + + dft.Value = null; + dft.TryToDateTimeOffset(out _).Should().BeFalse(); + Assert.ThrowsException(() => dft.ToDateTimeOffset()); + } + + [TestMethod] + public void ToDateTimeOffsetThrowsInvalidFormat() + { + var dft = new Date("T45:45:56"); + + Assert.ThrowsException(() => dft.ToDateTimeOffset()); + + dft.TryToDateTimeOffset(out var _).Should().BeFalse(); + } + + [TestMethod] + public void CanConvertToDateTime() + { + var dft = new Date(2023, 07, 11); + dft.TryToDate(out var dt).Should().BeTrue(); + dt.Days.Should().Be(11); + dt.Precision.Should().Be(ElementModel.Types.DateTimePrecision.Day); + + dft = new Date(2023, 7); + dft.TryToDate(out dt).Should().BeTrue(); + dt.Days.Should().BeNull(); + dt.Precision.Should().Be(ElementModel.Types.DateTimePrecision.Month); + + dft = new Date(null); + dft.TryToDate(out dt).Should().BeTrue(); + dt.Should().BeNull(); + } + + [TestMethod] + public void CacheImprovesSpeed() + { + var dts = "2023-07-11"; + var dt = new Date(dts); + _ = dt.TryToDate(out var _); // trigger initial compile of regex + + var sw = Stopwatch.StartNew(); + + for (var i = 0; i < 1000; i++) + { + // Clear the cache each invocation + dt.Value = dts; + _ = dt.TryToDate(out var _); + dt.Value = null; + } + + sw.Stop(); + Console.WriteLine(sw.Elapsed.ToString()); + + dt = new Date(dts); + + var sw2 = Stopwatch.StartNew(); + for (var i = 0; i < 1000; i++) + { + _ = dt.TryToDate(out var _); + } + sw2.Stop(); + + Console.WriteLine(sw2.Elapsed.ToString()); + + // It's actually about 20x faster on my machine + (sw2.Elapsed).Should().BeLessThan(sw.Elapsed); + } + } +} diff --git a/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs index dbb10f8d95..7bb2db79d2 100644 --- a/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs +++ b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs @@ -20,7 +20,7 @@ public class DateTimeTests [TestMethod] public void DateTimeHandling() { - FhirDateTime dt = new FhirDateTime("2010-01-01"); + FhirDateTime dt = new FhirDateTime(2010, 1, 1); Assert.AreEqual("2010-01-01", dt.Value); FhirDateTime dt2 = new FhirDateTime(1972, 11, 30, 15, 10, 0, TimeSpan.Zero); diff --git a/src/Hl7.Fhir.Support.Tests/ElementModel/DateTest.cs b/src/Hl7.Fhir.Support.Tests/ElementModel/DateTest.cs index 9421b20993..dce3798c45 100644 --- a/src/Hl7.Fhir.Support.Tests/ElementModel/DateTest.cs +++ b/src/Hl7.Fhir.Support.Tests/ElementModel/DateTest.cs @@ -1,4 +1,5 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using P = Hl7.Fhir.ElementModel.Types; @@ -27,7 +28,7 @@ public void DateConstructor() reject("2010-2-04"); } - void accept(string testInput, int? y, int? m, int? d, P.DateTimePrecision? p, TimeSpan? o) + private void accept(string testInput, int? y, int? m, int? d, P.DateTimePrecision? p, TimeSpan? o) { Assert.IsTrue(P.Date.TryParse(testInput, out P.Date parsed), "TryParse"); Assert.AreEqual(y, parsed.Years, "years"); @@ -38,7 +39,7 @@ void accept(string testInput, int? y, int? m, int? d, P.DateTimePrecision? p, Ti Assert.AreEqual(testInput, parsed.ToString(), "ToString"); } - void reject(string testValue) + private void reject(string testValue) { Assert.IsFalse(P.Date.TryParse(testValue, out _)); } @@ -135,5 +136,27 @@ public void FromDateTimeOffset() Assert.AreEqual(plusOne, partialDate.Offset); } + [TestMethod] + [DataRow("2001")] + [DataRow("2001-04")] + [DataRow("2001-04-06")] + [DataRow("2001-04-06+01:30")] + public void CanConvertToOriginalString(string format) + { + var parsed = P.Date.Parse(format); + parsed.ToString().Should().Be(format); + } + + [TestMethod] + [DataRow(P.DateTimePrecision.Year, false, "2001")] + [DataRow(P.DateTimePrecision.Month, false, "2001-04")] + [DataRow(P.DateTimePrecision.Day, false, "2001-04-06")] + [DataRow(P.DateTimePrecision.Day, true, "2001-04-06+01:00")] + public void CanConvertToString(P.DateTimePrecision p, bool hasOffset, string expected) + { + var dt = new DateTimeOffset(2001, 4, 6, 13, 1, 2, 890, TimeSpan.FromHours(1)); + var parsed = P.Date.FromDateTimeOffset(dt, p, hasOffset); + parsed.ToString().Should().Be(expected); + } } } \ No newline at end of file diff --git a/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs b/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs index 7bb60ff0dc..7092edecfb 100644 --- a/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs +++ b/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs @@ -172,8 +172,11 @@ public void CanConvertToOriginalString(string format) public void CanConvertToString(P.DateTimePrecision p, bool hasOffset, string expected) { var dt = new DateTimeOffset(2001, 4, 6, 13, 1, 2, 890, TimeSpan.FromHours(1)); - var parsed = new P.DateTime(dt, p, hasOffset); + var parsed = P.DateTime.FromDateTimeOffset(dt, p, hasOffset); parsed.ToString().Should().Be(expected); + + var rounded = P.DateTime.RoundToPrecision(dt, p, hasOffset); + P.DateTime.FormatDateTimeOffset(rounded).Should().Be(expected); } } } \ No newline at end of file From 1944b09926f97df89aef332eed5f19dbecc0f358 Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Tue, 18 Jul 2023 17:48:36 +0200 Subject: [PATCH 2/4] Also improved speed of Date comparators (plus a correction of FhirDateTime comparators) --- src/Hl7.Fhir.Base/Model/Date-comparators.cs | 18 ++++++------------ .../Model/FhirDateTime-comparators.cs | 7 +------ 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/Hl7.Fhir.Base/Model/Date-comparators.cs b/src/Hl7.Fhir.Base/Model/Date-comparators.cs index 9c9d980ea5..e221a27b68 100644 --- a/src/Hl7.Fhir.Base/Model/Date-comparators.cs +++ b/src/Hl7.Fhir.Base/Model/Date-comparators.cs @@ -28,9 +28,6 @@ POSSIBILITY OF SUCH DAMAGE. */ -using System; -using P = Hl7.Fhir.ElementModel.Types; - namespace Hl7.Fhir.Model { @@ -44,7 +41,7 @@ public partial class Date if (aValue == null) return bValue == null; if (bValue == null) return false; - return P.DateTime.Parse(a.Value) > P.DateTime.Parse(b.Value); + return a.ToDate() > b.ToDate(); } public static bool operator >=(Date a, Date b) @@ -55,7 +52,7 @@ public partial class Date if (aValue == null) return bValue == null; if (bValue == null) return false; - return P.DateTime.Parse(a.Value) >= P.DateTime.Parse(b.Value); + return a.ToDate() >= b.ToDate(); } public static bool operator <(Date a, Date b) @@ -66,7 +63,7 @@ public partial class Date if (aValue == null) return bValue == null; if (bValue == null) return false; - return P.DateTime.Parse(a.Value) < P.DateTime.Parse(b.Value); + return a.ToDate() < b.ToDate(); } public static bool operator <=(Date a, Date b) @@ -77,7 +74,7 @@ public partial class Date if (aValue == null) return bValue == null; if (bValue == null) return false; - return P.DateTime.Parse(a.Value) <= P.DateTime.Parse(b.Value); + return a.ToDate() <= b.ToDate(); } /// @@ -105,12 +102,9 @@ public override bool Equals(object obj) if (Value == null) return otherValue == null; if (otherValue == null) return false; - if (this.Value == otherValue) return true; // Default reference/string comparison works in most cases - - var left = P.DateTime.Parse(Value); - var right = P.DateTime.Parse(otherValue); + if (Value == otherValue) return true; // Default reference/string comparison works in most cases - return left == right; + return ToDate() == other.ToDate(); } else return false; diff --git a/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs b/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs index 417f22c8a2..4a86368875 100644 --- a/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs +++ b/src/Hl7.Fhir.Base/Model/FhirDateTime-comparators.cs @@ -28,8 +28,6 @@ POSSIBILITY OF SUCH DAMAGE. */ -using P = Hl7.Fhir.ElementModel.Types; - namespace Hl7.Fhir.Model { public partial class FhirDateTime @@ -103,10 +101,7 @@ public override bool Equals(object obj) if (Value == null) return otherValue == null; if (otherValue == null) return false; - if (this.Value == otherValue) return true; // Default reference/string comparison works in most cases - - var left = P.DateTime.Parse(Value); - var right = P.DateTime.Parse(otherValue); + if (Value == otherValue) return true; // Default reference/string comparison works in most cases return ToDateTime() == other.ToDateTime(); } From 865ad796c268e0e249362c3592793f05ef2db6ab Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Tue, 18 Jul 2023 18:53:46 +0200 Subject: [PATCH 3/4] Today I learned the differences between fractions and milliseconds. --- src/Hl7.Fhir.Base/ElementModel/Types/Date.cs | 2 +- src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs | 6 ++---- .../ElementModel/DateTimeTest.cs | 15 +++++++++++++++ .../Tests/BasicFunctionTests.cs | 4 ++-- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs b/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs index b497ca52e4..0314d57e9e 100644 --- a/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs +++ b/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs @@ -21,7 +21,7 @@ private Date(DateTimeOffset value, DateTimePrecision precision, bool hasOffset) { if (precision > DateTimePrecision.Day) throw new ArgumentException($"Invalid precision {precision}, cannot be more than {nameof(DateTimePrecision.Day)}.", nameof(precision)); - _value = value; + _value = DateTime.RoundToPrecision(value, precision, hasOffset); Precision = precision; HasOffset = hasOffset; } diff --git a/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs b/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs index b68c1eee2f..b42ad0f4a5 100644 --- a/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs +++ b/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs @@ -44,7 +44,7 @@ public static DateTime Parse(string representation) => DateTimePrecision.Hour => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, 0, 0, withOffset ? source.Offset : TimeSpan.Zero), DateTimePrecision.Minute => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, source.Minute, 0, withOffset ? source.Offset : TimeSpan.Zero), DateTimePrecision.Second => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, source.Minute, source.Second, withOffset ? source.Offset : TimeSpan.Zero), - _ => new DateTimeOffset(source.Year, source.Month, source.Day, source.Hour, source.Minute, source.Second, source.Millisecond, withOffset ? source.Offset : TimeSpan.Zero), + _ => new DateTimeOffset(source.Ticks, withOffset ? source.Offset : TimeSpan.Zero), }; public static DateTime FromDateTimeOffset(DateTimeOffset dto, DateTimePrecision prec = DateTimePrecision.Fraction, bool includeOffset = true) => @@ -96,9 +96,7 @@ public DateTimeOffset ToDateTimeOffset(TimeSpan defaultOffset) => HasOffset switch { true => _value, - false => new(_value.Year, _value.Month, _value.Day, - _value.Hour, _value.Minute, _value.Second, _value.Millisecond, - defaultOffset) + false => new(_value.Ticks, defaultOffset) }; public const string FMT_FULL = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; diff --git a/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs b/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs index 7092edecfb..cc3cf5944e 100644 --- a/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs +++ b/src/Hl7.Fhir.Support.Tests/ElementModel/DateTimeTest.cs @@ -174,7 +174,22 @@ public void CanConvertToString(P.DateTimePrecision p, bool hasOffset, string exp var dt = new DateTimeOffset(2001, 4, 6, 13, 1, 2, 890, TimeSpan.FromHours(1)); var parsed = P.DateTime.FromDateTimeOffset(dt, p, hasOffset); parsed.ToString().Should().Be(expected); + } + + [TestMethod] + [DataRow(P.DateTimePrecision.Year, false, "2001-01-01T00:00:00+00:00")] + [DataRow(P.DateTimePrecision.Month, false, "2001-04-01T00:00:00+00:00")] + [DataRow(P.DateTimePrecision.Day, false, "2001-04-06T00:00:00+00:00")] + [DataRow(P.DateTimePrecision.Day, true, "2001-04-06T00:00:00+01:00")] + [DataRow(P.DateTimePrecision.Hour, false, "2001-04-06T13:00:00+00:00")] + [DataRow(P.DateTimePrecision.Minute, false, "2001-04-06T13:01:00+00:00")] + [DataRow(P.DateTimePrecision.Second, false, "2001-04-06T13:01:02+00:00")] + [DataRow(P.DateTimePrecision.Second, true, "2001-04-06T13:01:02+01:00")] + [DataRow(P.DateTimePrecision.Fraction, true, "2001-04-06T13:01:02.89+01:00")] + public void CanRound(P.DateTimePrecision p, bool hasOffset, string expected) + { + var dt = new DateTimeOffset(2001, 4, 6, 13, 1, 2, 890, TimeSpan.FromHours(1)); var rounded = P.DateTime.RoundToPrecision(dt, p, hasOffset); P.DateTime.FormatDateTimeOffset(rounded).Should().Be(expected); } diff --git a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs index b60120706b..69831bab2c 100644 --- a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs +++ b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs @@ -10,9 +10,8 @@ //extern alias dstu2; using Hl7.Fhir.ElementModel; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Collections.Generic; using Hl7.FhirPath.Functions; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Linq; using P = Hl7.Fhir.ElementModel.Types; @@ -186,6 +185,7 @@ public void TestSubstring() public void TestExpressionTodayFunction() { // Check that date comes in + var s = scalar("today()"); Assert.AreEqual(P.Date.Today(), scalar("today()")); // Check greater than From 4916b74f9376ebcb74397e691ddb144be5e18aff Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Wed, 19 Jul 2023 13:28:44 +0200 Subject: [PATCH 4/4] Changes after PR. --- src/Benchmarks/Benchmarks.csproj | 1 - src/Benchmarks/DateTimeBenchmark.cs | 62 +++++++++++++++++++ src/Hl7.Fhir.Base/ElementModel/Types/Date.cs | 5 +- .../ElementModel/Types/DateTime.cs | 6 +- src/Hl7.Fhir.Base/Model/Date.cs | 9 ++- .../Model/DateTests.cs | 38 +----------- .../Model/DateTimeTests.cs | 37 ++--------- .../Tests/BasicFunctionTests.cs | 1 - 8 files changed, 77 insertions(+), 82 deletions(-) create mode 100644 src/Benchmarks/DateTimeBenchmark.cs diff --git a/src/Benchmarks/Benchmarks.csproj b/src/Benchmarks/Benchmarks.csproj index b42668099c..2896720b8b 100644 --- a/src/Benchmarks/Benchmarks.csproj +++ b/src/Benchmarks/Benchmarks.csproj @@ -4,7 +4,6 @@ Exe net6.0 Benchmarks - Firely.Sdk.Benchmarks Firely.Sdk.Benchmarks diff --git a/src/Benchmarks/DateTimeBenchmark.cs b/src/Benchmarks/DateTimeBenchmark.cs new file mode 100644 index 0000000000..7eb86dff16 --- /dev/null +++ b/src/Benchmarks/DateTimeBenchmark.cs @@ -0,0 +1,62 @@ +using BenchmarkDotNet.Attributes; +using Hl7.Fhir.Model; +using System; + +namespace Firely.Sdk.Benchmarks +{ + public class DateTimeBenchmarks + { + [GlobalSetup] + public void BenchmarkSetup() + { + _dateTimeInstance = new FhirDateTime(DATETIME); + _ = _dateTimeInstance.TryToDateTime(out var _); // trigger initial compile of regex + + _dateInstance = new Date(DATE); + _ = _dateInstance.TryToDate(out var _); // trigger initial compile of regex + } + + private const string DATETIME = "2023-07-11T13:00:00"; + private FhirDateTime _dateTimeInstance; + + [Benchmark] + public DateTimeOffset DateTimeToDTO_Uncached() + { + // Clear the cache each invocation + _dateTimeInstance.Value = DATETIME; + _ = _dateTimeInstance.TryToDateTimeOffset(TimeSpan.Zero, out var result); + _dateTimeInstance.Value = null; + + return result; + } + + [Benchmark] + public DateTimeOffset DateTimeToDTO_Cached() + { + _ = _dateTimeInstance.TryToDateTimeOffset(TimeSpan.Zero, out var result); + return result; + } + + private const string DATE = "2023-07-11"; + private Date _dateInstance; + + + [Benchmark] + public DateTimeOffset DateToDTO_Uncached() + { + // Clear the cache each invocation + _dateInstance.Value = DATETIME; + _ = _dateInstance.TryToDateTimeOffset(out var result); + _dateInstance.Value = null; + + return result; + } + + [Benchmark] + public DateTimeOffset DateToDTO_Cached() + { + _ = _dateInstance.TryToDateTimeOffset(out var result); + return result; + } + } +} diff --git a/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs b/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs index 0314d57e9e..f1119f5202 100644 --- a/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs +++ b/src/Hl7.Fhir.Base/ElementModel/Types/Date.cs @@ -27,7 +27,7 @@ private Date(DateTimeOffset value, DateTimePrecision precision, bool hasOffset) } public static Date Parse(string representation) => - TryParse(representation, out var result) ? result! : throw new FormatException($"String '{representation}' was not recognized as a valid date."); + TryParse(representation, out var result) ? result : throw new FormatException($"String '{representation}' was not recognized as a valid date."); public static bool TryParse(string representation, out Date value) => tryParse(representation, out value); @@ -69,7 +69,8 @@ public static Date FromDateTimeOffset(DateTimeOffset dto, DateTimePrecision prec /// /// Converts the date to a full DateTimeOffset instance. /// - public DateTimeOffset ToDateTimeOffset() => ToDateTimeOffset(0, 0, 0, TimeSpan.Zero); + public DateTimeOffset ToDateTimeOffset(TimeSpan defaultOffset) => + HasOffset ? _value : new(_value.Ticks, defaultOffset); /// /// Converts the date to a full DateTimeOffset instance. diff --git a/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs b/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs index b42ad0f4a5..df4f406c07 100644 --- a/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs +++ b/src/Hl7.Fhir.Base/ElementModel/Types/DateTime.cs @@ -93,11 +93,7 @@ public static DateTime FromDateTimeOffset(DateTimeOffset dto, DateTimePrecision /// Offset used when the datetime does not specify one. /// public DateTimeOffset ToDateTimeOffset(TimeSpan defaultOffset) => - HasOffset switch - { - true => _value, - false => new(_value.Ticks, defaultOffset) - }; + HasOffset ? _value : new(_value.Ticks, defaultOffset); public const string FMT_FULL = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; diff --git a/src/Hl7.Fhir.Base/Model/Date.cs b/src/Hl7.Fhir.Base/Model/Date.cs index e93ad937ad..cbfede1e61 100644 --- a/src/Hl7.Fhir.Base/Model/Date.cs +++ b/src/Hl7.Fhir.Base/Model/Date.cs @@ -111,17 +111,16 @@ protected override void OnObjectValueChanged() /// Converts this Fhir Fhir Date to a . /// /// A DateTimeOffset filled out to midnight, january 1 (UTC) in case of a partial date. - public DateTimeOffset ToDateTimeOffset() + public DateTimeOffset? ToDateTimeOffset() { - if (Value == null) throw new InvalidOperationException("Date's value is null"); + if (Value == null) return null; // Note: this behaviour is inconsistent with ToDateTimeOffset() in FhirDateTime // ToDateTimeOffset() will convert partial date/times by filling out to midnight/january 1 UTC - // When there's no timezone, the UTC is assumed if (!TryToDate(out var dt)) throw new FormatException($"String '{Value}' was not recognized as a valid datetime."); // Since Value is not null and the parsed value is valid, dto will not be null - return dt!.ToDateTimeOffset(); + return dt!.ToDateTimeOffset(TimeSpan.Zero); } /// @@ -132,7 +131,7 @@ public bool TryToDateTimeOffset(out DateTimeOffset dto) { if (Value is not null && TryToDate(out var dt)) { - dto = dt!.ToDateTimeOffset(); + dto = dt!.ToDateTimeOffset(TimeSpan.Zero); return true; } else diff --git a/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs index b0cfd468ca..5ed71246af 100644 --- a/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs +++ b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTests.cs @@ -10,7 +10,6 @@ using Hl7.Fhir.Model; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; -using System.Diagnostics; namespace Hl7.Fhir.Tests.Model { @@ -83,7 +82,7 @@ public void UpdatesCachedValue() dft.Value = null; dft.TryToDateTimeOffset(out _).Should().BeFalse(); - Assert.ThrowsException(() => dft.ToDateTimeOffset()); + dft.ToDateTimeOffset().Should().BeNull(); } [TestMethod] @@ -113,40 +112,5 @@ public void CanConvertToDateTime() dft.TryToDate(out dt).Should().BeTrue(); dt.Should().BeNull(); } - - [TestMethod] - public void CacheImprovesSpeed() - { - var dts = "2023-07-11"; - var dt = new Date(dts); - _ = dt.TryToDate(out var _); // trigger initial compile of regex - - var sw = Stopwatch.StartNew(); - - for (var i = 0; i < 1000; i++) - { - // Clear the cache each invocation - dt.Value = dts; - _ = dt.TryToDate(out var _); - dt.Value = null; - } - - sw.Stop(); - Console.WriteLine(sw.Elapsed.ToString()); - - dt = new Date(dts); - - var sw2 = Stopwatch.StartNew(); - for (var i = 0; i < 1000; i++) - { - _ = dt.TryToDate(out var _); - } - sw2.Stop(); - - Console.WriteLine(sw2.Elapsed.ToString()); - - // It's actually about 20x faster on my machine - (sw2.Elapsed).Should().BeLessThan(sw.Elapsed); - } } } diff --git a/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs index 7bb2db79d2..87c041bdc8 100644 --- a/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs +++ b/src/Hl7.Fhir.Support.Poco.Tests/Model/DateTimeTests.cs @@ -10,7 +10,6 @@ using Hl7.Fhir.Model; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; -using System.Diagnostics; namespace Hl7.Fhir.Tests.Model { @@ -172,38 +171,14 @@ public void CanConvertToDateTime() } [TestMethod] - public void CacheImprovesSpeed() + public void RetainsFractions() { - var dts = "2023-07-11T13:00:00"; - var dt = new FhirDateTime(dts); - _ = dt.TryToDateTime(out var _); // trigger initial compile of regex + var input = @"2020-04-17T10:24:13.1882432-05:00"; + var datetime = ElementModel.Types.DateTime.Parse(input); + var offset = datetime.ToDateTimeOffset(TimeSpan.Zero); + var output = ElementModel.Types.DateTime.FormatDateTimeOffset(offset); - var sw = Stopwatch.StartNew(); - - for (var i = 0; i < 1000; i++) - { - // Clear the cache each invocation - dt.Value = dts; - _ = dt.TryToDateTimeOffset(TimeSpan.Zero, out var _); - dt.Value = null; - } - - sw.Stop(); - Console.WriteLine(sw.Elapsed.ToString()); - - dt = new FhirDateTime(dts); - - var sw2 = Stopwatch.StartNew(); - for (var i = 0; i < 1000; i++) - { - _ = dt.TryToDateTimeOffset(TimeSpan.Zero, out var _); - } - sw2.Stop(); - - Console.WriteLine(sw2.Elapsed.ToString()); - - // It's actually about 20x faster on my machine - (sw2.Elapsed).Should().BeLessThan(sw.Elapsed); + output.Should().Be(input); } } } diff --git a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs index 69831bab2c..00d6831050 100644 --- a/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs +++ b/src/Hl7.FhirPath.Tests/Tests/BasicFunctionTests.cs @@ -185,7 +185,6 @@ public void TestSubstring() public void TestExpressionTodayFunction() { // Check that date comes in - var s = scalar("today()"); Assert.AreEqual(P.Date.Today(), scalar("today()")); // Check greater than