|
| 1 | +using System.Globalization; |
| 2 | + |
1 | 3 | namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration |
2 | 4 | { |
3 | 5 | /// <summary> |
@@ -54,27 +56,75 @@ internal static void ValidateExclusiveFields( |
54 | 56 | } |
55 | 57 |
|
56 | 58 | /// <summary> |
57 | | - /// Parses a <see cref="DateTime"/> from an attribute string value, throwing when |
58 | | - /// the value is present but cannot be parsed. |
| 59 | + /// Parses a policy <c>InitialStart</c> <see cref="DateTime"/> from an attribute string value, |
| 60 | + /// throwing when the value is present but cannot be parsed. The result is always |
| 61 | + /// <see cref="DateTimeKind.Utc"/>. |
59 | 62 | /// </summary> |
| 63 | + /// <remarks> |
| 64 | + /// Parsing uses <see cref="CultureInfo.InvariantCulture"/> with |
| 65 | + /// <see cref="DateTimeStyles.AssumeUniversal"/> | <see cref="DateTimeStyles.AdjustToUniversal"/> |
| 66 | + /// so the produced instant does not depend on the machine's local time zone. Strings carrying an |
| 67 | + /// explicit designator ("Z" or an offset) convert to UTC correctly; strings without a designator |
| 68 | + /// are interpreted as already being UTC. Treating unsuffixed values as UTC is the only |
| 69 | + /// machine-independent interpretation: the alternative (local time) would render a different |
| 70 | + /// literal into migrations on every machine and produce phantom alter-policy operations. |
| 71 | + /// </remarks> |
60 | 72 | /// <param name="rawValue">The raw attribute string to parse.</param> |
61 | 73 | /// <param name="entityName">The CLR type name of the entity, for use in exception messages.</param> |
62 | 74 | /// <param name="attributeName">The attribute name shown in the exception message prefix.</param> |
63 | | - /// <returns>The parsed <see cref="DateTime"/>, or <see langword="null"/> when <paramref name="rawValue"/> is null or whitespace.</returns> |
| 75 | + /// <returns>The parsed UTC <see cref="DateTime"/>, or <see langword="null"/> when <paramref name="rawValue"/> is null or whitespace.</returns> |
64 | 76 | internal static DateTime? ParseInitialStart(string? rawValue, string? entityName, string attributeName) |
65 | 77 | { |
66 | 78 | if (string.IsNullOrWhiteSpace(rawValue)) |
67 | 79 | { |
68 | 80 | return null; |
69 | 81 | } |
70 | 82 |
|
71 | | - if (DateTime.TryParse(rawValue, out DateTime parsed)) |
| 83 | + if (DateTime.TryParse( |
| 84 | + rawValue, |
| 85 | + CultureInfo.InvariantCulture, |
| 86 | + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, |
| 87 | + out DateTime parsed)) |
72 | 88 | { |
73 | 89 | return parsed; |
74 | 90 | } |
75 | 91 |
|
76 | 92 | throw new InvalidOperationException( |
77 | 93 | $"{attributeName} on '{entityName}': InitialStart '{rawValue}' is not a valid DateTime format. Use an ISO 8601 string."); |
78 | 94 | } |
| 95 | + |
| 96 | + /// <summary> |
| 97 | + /// Normalizes a policy <c>InitialStart</c> value to a machine-independent UTC instant so that |
| 98 | + /// values written by the fluent API, parsed from attributes, and read back from snapshots all |
| 99 | + /// compare on the same footing. |
| 100 | + /// </summary> |
| 101 | + /// <remarks> |
| 102 | + /// Kind handling: |
| 103 | + /// <list type="bullet"> |
| 104 | + /// <item><see cref="DateTimeKind.Utc"/>: returned unchanged.</item> |
| 105 | + /// <item><see cref="DateTimeKind.Local"/>: converted via <see cref="DateTime.ToUniversalTime"/>.</item> |
| 106 | + /// <item><see cref="DateTimeKind.Unspecified"/>: reinterpreted as UTC via |
| 107 | + /// <see cref="DateTime.SpecifyKind"/> (NOT <see cref="DateTime.ToUniversalTime"/>, which would |
| 108 | + /// treat it as local and reintroduce a machine dependency). This matches the attribute path's |
| 109 | + /// "unsuffixed = UTC" rule.</item> |
| 110 | + /// </list> |
| 111 | + /// </remarks> |
| 112 | + /// <param name="value">The value to normalize.</param> |
| 113 | + /// <returns>The equivalent UTC <see cref="DateTime"/>.</returns> |
| 114 | + internal static DateTime NormalizeInitialStartToUtc(DateTime value) => value.Kind switch |
| 115 | + { |
| 116 | + DateTimeKind.Utc => value, |
| 117 | + DateTimeKind.Local => value.ToUniversalTime(), |
| 118 | + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), |
| 119 | + }; |
| 120 | + |
| 121 | + /// <summary> |
| 122 | + /// Nullable overload of <see cref="NormalizeInitialStartToUtc(DateTime)"/>; returns |
| 123 | + /// <see langword="null"/> unchanged. |
| 124 | + /// </summary> |
| 125 | + /// <param name="value">The value to normalize, or <see langword="null"/>.</param> |
| 126 | + /// <returns>The equivalent UTC <see cref="DateTime"/>, or <see langword="null"/>.</returns> |
| 127 | + internal static DateTime? NormalizeInitialStartToUtc(DateTime? value) |
| 128 | + => value.HasValue ? NormalizeInitialStartToUtc(value.Value) : null; |
79 | 129 | } |
80 | 130 | } |
0 commit comments