1919import edu .umd .cs .findbugs .annotations .SuppressFBWarnings ;
2020import java .time .Instant ;
2121import java .time .LocalDate ;
22+ import java .time .LocalDateTime ;
2223import java .time .OffsetDateTime ;
2324import java .time .ZoneOffset ;
2425import java .time .format .DateTimeFormatter ;
2829import java .util .List ;
2930import java .util .Objects ;
3031import java .util .Optional ;
32+ import java .util .regex .Matcher ;
33+ import java .util .regex .Pattern ;
3134
3235/**
3336 * Represents a Cedar datetime extension value. DateTime values are encoded as strings in the
@@ -44,50 +47,137 @@ public class DateTime extends Value {
4447
4548 private static class DateTimeValidator {
4649
47- private static final List <DateTimeFormatter > FORMATTERS = Arrays .asList (
50+ private static final Pattern OFFSET_PATTERN = Pattern .compile ("([+-])(\\ d{2})(\\ d{2})$" );
51+
52+ // Formatters for UTC datetime
53+ private static final List <DateTimeFormatter > UTC_FORMATTERS = Arrays .asList (
4854 DateTimeFormatter .ofPattern ("uuuu-MM-dd" ).withResolverStyle (ResolverStyle .STRICT ),
49- DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ss'Z'" )
50- .withResolverStyle (ResolverStyle .STRICT ),
51- DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'" )
55+ DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ssX" )
5256 .withResolverStyle (ResolverStyle .STRICT ),
53- DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ssXX" )
54- .withResolverStyle (ResolverStyle .STRICT ),
55- DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ss.SSSXX" )
57+ DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ss.SSSX" )
5658 .withResolverStyle (ResolverStyle .STRICT ));
5759
60+ // Formatters for local datetime parts (without offset)
61+ private static final List <DateTimeFormatter > LOCAL_FORMATTERS = Arrays .asList (
62+ DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ss" ).withResolverStyle (ResolverStyle .STRICT ),
63+ DateTimeFormatter .ofPattern ("uuuu-MM-dd'T'HH:mm:ss.SSS" ).withResolverStyle (ResolverStyle .STRICT ));
64+
65+ // Earliest valid instant: 0000-01-01T00:00:00+2359
66+ private static final Instant MIN_INSTANT = Instant .ofEpochMilli (-62167305540000L );
67+ // Latest valid instant: 9999-12-31T23:59:59-2359
68+ private static final Instant MAX_INSTANT = Instant .ofEpochMilli (253402387139000L );
69+
70+ /**
71+ * Validates that the instant is within the allowed range.
72+ *
73+ * @param instant the parsed instant to validate
74+ * @return true if the instant is valid, false otherwise
75+ */
76+ private static boolean isValidInstant (Instant instant ) {
77+ return !instant .isBefore (MIN_INSTANT ) && !instant .isAfter (MAX_INSTANT );
78+ }
79+
5880 /**
59- * Parses a datetime string and returns the parsed Instant. Combines validation and parsing
60- * into a single operation to avoid redundancy. All datetime formats are normalized to
61- * Instant for consistent equality comparison.
81+ * Parses a datetime string and returns the parsed Instant.
6282 *
6383 * @param dateTimeString the string to parse
6484 * @return Optional containing the parsed Instant, or empty if parsing fails
6585 */
6686 private static Optional <Instant > parseToInstant (String dateTimeString ) {
6787 if (dateTimeString == null || dateTimeString .trim ().isEmpty ()) {
68- return java .util .Optional .empty ();
88+ return Optional .empty ();
89+ }
90+
91+ Matcher offsetMatcher = OFFSET_PATTERN .matcher (dateTimeString );
92+
93+ Optional <Instant > result ;
94+ if (offsetMatcher .find ()) {
95+ result = parseWithCustomOffset (dateTimeString , offsetMatcher );
96+ } else {
97+ result = UTC_FORMATTERS .stream ()
98+ .flatMap (formatter -> tryParseUTCDateTime (dateTimeString , formatter ).stream ())
99+ .findFirst ();
69100 }
70101
71- return FORMATTERS .stream ()
72- .flatMap (formatter -> tryParseWithFormatter (dateTimeString , formatter ).stream ())
73- .findFirst ();
102+ // Validate instant range
103+ if (result .isPresent () && !isValidInstant (result .get ())) {
104+ return Optional .empty ();
105+ }
106+
107+ return result ;
74108 }
75109
76110 /**
77- * Attempts to parse a datetime string with a specific formatter.
111+ * Parses datetime string with custom offset handling for extreme values.
112+ *
113+ * @param dateTimeString the full datetime string
114+ * @param offsetMatcher the matcher that found the offset pattern
115+ * @return Optional containing the parsed Instant, or empty if parsing fails
116+ */
117+ private static Optional <Instant > parseWithCustomOffset (String dateTimeString , Matcher offsetMatcher ) {
118+ try {
119+ String sign = offsetMatcher .group (1 );
120+ int offsetHours = Integer .parseInt (offsetMatcher .group (2 ));
121+ int offsetMinutes = Integer .parseInt (offsetMatcher .group (3 ));
122+
123+ if (offsetHours > 23 || offsetMinutes > 59 ) {
124+ return Optional .empty ();
125+ }
126+
127+ String dateTimeWithoutOffset = dateTimeString .substring (0 , offsetMatcher .start ());
128+
129+ Optional <LocalDateTime > localDateTime = LOCAL_FORMATTERS .stream ()
130+ .flatMap (formatter -> tryParseLocalDateTime (dateTimeWithoutOffset , formatter ).stream ())
131+ .findFirst ();
132+
133+ if (localDateTime .isEmpty ()) {
134+ return Optional .empty ();
135+ }
136+
137+ long epochMillis = localDateTime .get ().toInstant (ZoneOffset .UTC ).toEpochMilli ();
138+ long offsetMillis = convertOffsetToMilliseconds (sign , offsetHours , offsetMinutes );
139+ long adjustedEpochMillis = epochMillis - offsetMillis ;
140+
141+ return Optional .of (Instant .ofEpochMilli (adjustedEpochMillis ));
142+
143+ } catch (Exception e ) {
144+ return Optional .empty ();
145+ }
146+ }
147+
148+ /**
149+ * Attempts to parse a local datetime string with a specific formatter.
150+ *
151+ * @param dateTimeString the string to parse
152+ * @param formatter the formatter to use
153+ * @return Optional containing the parsed LocalDateTime, or empty if parsing fails
154+ */
155+ private static Optional <LocalDateTime > tryParseLocalDateTime (String dateTimeString , DateTimeFormatter formatter ) {
156+ try {
157+ return Optional .of (LocalDateTime .parse (dateTimeString , formatter ));
158+ } catch (DateTimeParseException e ) {
159+ return Optional .empty ();
160+ }
161+ }
162+
163+ /**
164+ * Attempts to parse a UTC datetime string with a specific formatter.
78165 *
79166 * @param dateTimeString the string to parse
80167 * @param formatter the formatter to use
81168 * @return Optional containing the parsed Instant, or empty if parsing fails
82169 */
83- private static Optional <Instant > tryParseWithFormatter (String dateTimeString ,
84- DateTimeFormatter formatter ) {
170+ private static Optional <Instant > tryParseUTCDateTime (String dateTimeString , DateTimeFormatter formatter ) {
85171 try {
86- if (formatter == FORMATTERS .get (0 )) {
172+ if (formatter == UTC_FORMATTERS .get (0 )) {
87173 // Date-only format - convert to start of day UTC
88174 LocalDate date = LocalDate .parse (dateTimeString , formatter );
89175 return Optional .of (date .atStartOfDay (ZoneOffset .UTC ).toInstant ());
90176 } else {
177+ // UTC format - only accept 'Z' as timezone, not other offsets
178+ if (!dateTimeString .endsWith ("Z" )) {
179+ return Optional .empty ();
180+ }
91181 // DateTime format - parse and convert to Instant
92182 OffsetDateTime dateTime = OffsetDateTime .parse (dateTimeString , formatter );
93183 return Optional .of (dateTime .toInstant ());
@@ -96,6 +186,20 @@ private static Optional<Instant> tryParseWithFormatter(String dateTimeString,
96186 return Optional .empty ();
97187 }
98188 }
189+
190+ /**
191+ * Converts timezone offset to milliseconds.
192+ *
193+ * @param sign the sign of the offset ("+" or "-")
194+ * @param hours the hours component of the offset
195+ * @param minutes the minutes component of the offset
196+ * @return offset in milliseconds
197+ */
198+ private static long convertOffsetToMilliseconds (String sign , int hours , int minutes ) {
199+ long totalMinutes = hours * 60L + minutes ;
200+ long milliseconds = totalMinutes * 60L * 1000L ;
201+ return "+" .equals (sign ) ? milliseconds : -milliseconds ;
202+ }
99203 }
100204
101205 /** Datetime as a string. */
@@ -157,4 +261,8 @@ public int hashCode() {
157261 public String toString () {
158262 return dateTime ;
159263 }
264+
265+ public long toEpochMilli () {
266+ return parsedInstant .toEpochMilli ();
267+ }
160268}
0 commit comments