001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.marshall.utils; 018 019import static org.apache.juneau.commons.reflect.ReflectionUtils.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021import static org.apache.juneau.commons.utils.ThrowableUtils.*; 022 023import java.lang.reflect.*; 024import java.time.*; 025import java.time.Duration; 026import java.time.format.*; 027import java.time.temporal.*; 028import java.util.*; 029 030import javax.xml.datatype.*; 031 032import org.apache.juneau.commons.reflect.*; 033import org.apache.juneau.marshall.*; 034import org.apache.juneau.marshall.swaps.*; 035 036/** 037 * Centralized ISO 8601 formatting and parsing utility for date/time and Duration types. 038 * 039 * <p> 040 * Provides the built-in serialization format for {@link Temporal}, {@link Calendar}, 041 * {@link Date}, {@link XMLGregorianCalendar}, and {@link Duration} objects. 042 * 043 * <h5 class='section'>See Also:</h5><ul> 044 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/Marshalling">Marshalling</a> 045 * </ul> 046 */ 047@SuppressWarnings({ 048 "java:S1172", // The ClassMeta sourceType/type parameters of the public formatDate/formatCalendar/formatTemporal/formatAsDate/formatAsDateTime are unused by the current logic but kept for API stability 049 "unchecked" // Casts of parsed Duration/Period/Calendar/Date/Temporal values to T are guaranteed by the requested target ClassMeta<T> 050}) 051public final class Iso8601Utils { 052 053 private Iso8601Utils() {} 054 055 private static final ZoneId Z = ZoneId.of("Z"); 056 057 private static final DateTimeFormatter YEAR_FORMATTER = DateTimeFormatter.ofPattern("uuuu"); 058 private static final DateTimeFormatter YEAR_MONTH_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM"); 059 060 private static final Map<Class<?>,DateTimeFormatter> DEFAULT_FORMATTERS = Map.ofEntries( 061 Map.entry(Instant.class, DateTimeFormatter.ISO_INSTANT), 062 Map.entry(ZonedDateTime.class, DateTimeFormatter.ISO_OFFSET_DATE_TIME), 063 Map.entry(OffsetDateTime.class, DateTimeFormatter.ISO_OFFSET_DATE_TIME), 064 Map.entry(LocalDate.class, DateTimeFormatter.ISO_LOCAL_DATE), 065 Map.entry(LocalDateTime.class, DateTimeFormatter.ISO_LOCAL_DATE_TIME), 066 Map.entry(LocalTime.class, DateTimeFormatter.ISO_LOCAL_TIME), 067 Map.entry(OffsetTime.class, DateTimeFormatter.ISO_OFFSET_TIME), 068 Map.entry(Year.class, YEAR_FORMATTER), 069 Map.entry(YearMonth.class, YEAR_MONTH_FORMATTER) 070 ); 071 072 private static DatatypeFactory datatypeFactory; 073 static { 074 try { 075 datatypeFactory = DatatypeFactory.newInstance(); 076 } catch (DatatypeConfigurationException e) { 077 throw toRex(e); 078 } 079 } 080 081 //----------------------------------------------------------------------------------------------------------------- 082 // Formatting 083 //----------------------------------------------------------------------------------------------------------------- 084 085 /** 086 * Formats a date/time or Duration value to its ISO 8601 string representation. 087 * 088 * <p> 089 * Convenience dispatcher that uses default per-type wire formats; callers that need format-aware 090 * output should use the per-type helpers directly: 091 * {@link #formatDate}, {@link #formatCalendar}, {@link #formatTemporal}, 092 * {@link #formatDuration}, {@link #formatPeriod}. 093 * 094 * @param value The value to format. <jk>null</jk> returns <jk>null</jk> (consistent with the per-type helpers). 095 * @param type The class metadata for the value (used for selecting the appropriate formatter for Temporal types). 096 * @param timeZone The session time zone (used when the value lacks zone info). 097 * @return The ISO 8601 string representation, or <jk>null</jk> if {@code value} is <jk>null</jk>. 098 */ 099 public static String format(Object value, ClassMeta<?> type, TimeZone timeZone) { 100 if (value == null) 101 return null; 102 if (value instanceof Duration value2) 103 return formatDuration(value2, DurationFormat.ISO_8601_WITH_DAYS); 104 if (value instanceof Period value2) 105 return formatPeriod(value2, PeriodFormat.ISO_8601); 106 if (value instanceof Calendar || value instanceof XMLGregorianCalendar) 107 return formatCalendar(value, type, CalendarFormat.ISO_OFFSET_DATE_TIME, timeZone); 108 if (value instanceof Date value2) 109 return formatDate(value2, type, DateFormat.ISO_LOCAL_DATE_TIME, timeZone); 110 if (value instanceof TemporalAccessor value2) 111 return formatTemporal(value2, type, TemporalFormat.DEFAULT, timeZone); 112 return value.toString(); 113 } 114 115 /** 116 * Formats a {@link Duration} value using the supplied wire format. 117 * 118 * @param value The value to format. <jk>null</jk> returns <jk>null</jk>. 119 * @param format The duration wire format. May be <jk>null</jk> (defaults to {@link DurationFormat#ISO_8601_WITH_DAYS}). 120 * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>. 121 */ 122 public static String formatDuration(Duration value, DurationFormat format) { 123 if (value == null) 124 return null; 125 return (format == null ? DurationFormat.ISO_8601_WITH_DAYS : format).format(value); 126 } 127 128 /** 129 * Formats a {@link Period} value using the supplied wire format. 130 * 131 * @param value The value to format. <jk>null</jk> returns <jk>null</jk>. 132 * @param format The period wire format. May be <jk>null</jk> (defaults to {@link PeriodFormat#ISO_8601}). 133 * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>. 134 */ 135 public static String formatPeriod(Period value, PeriodFormat format) { 136 if (value == null) 137 return null; 138 return (format == null ? PeriodFormat.ISO_8601 : format).format(value); 139 } 140 141 /** 142 * Formats a {@link Date} value using the supplied wire format. 143 * 144 * <p> 145 * {@code java.sql.Date}, {@code java.sql.Time}, and {@code java.sql.Timestamp} subclasses all dispatch 146 * through {@link DateFormat#format(Date, ZoneId)} just like {@code Date}. 147 * 148 * @param value The value to format. <jk>null</jk> returns <jk>null</jk>. 149 * @param sourceType The class metadata for the value (currently unused, kept for symmetry with the parse helper). 150 * @param format The date wire format. May be <jk>null</jk> (defaults to {@link DateFormat#ISO_LOCAL_DATE_TIME}). 151 * @param timeZone The session time zone (used as the default zone). 152 * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>. 153 */ 154 public static String formatDate(Date value, ClassMeta<?> sourceType, DateFormat format, TimeZone timeZone) { 155 if (value == null) 156 return null; 157 ZoneId zone = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 158 return (format == null ? DateFormat.ISO_LOCAL_DATE_TIME : format).format(value, zone); 159 } 160 161 /** 162 * Formats a {@link Calendar} or {@link XMLGregorianCalendar} value using the supplied wire format. 163 * 164 * <p> 165 * {@link XMLGregorianCalendar} values always emit {@link XMLGregorianCalendar#toXMLFormat()} regardless 166 * of the configured {@code format} — mirroring the parse-side rule and the historical wire output. 167 * {@link Calendar} (including {@link GregorianCalendar}) values are formatted using the supplied 168 * {@link CalendarFormat}. 169 * 170 * @param value The value to format. Must be a {@link Calendar} or {@link XMLGregorianCalendar}. 171 * <jk>null</jk> returns <jk>null</jk>. 172 * @param sourceType The class metadata for the value (currently unused, kept for symmetry with the parse helper). 173 * @param format The calendar wire format. May be <jk>null</jk> (defaults to {@link CalendarFormat#ISO_OFFSET_DATE_TIME}). 174 * @param timeZone The session time zone (used as the default zone when the value lacks zone info). 175 * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>. 176 */ 177 public static String formatCalendar(Object value, ClassMeta<?> sourceType, CalendarFormat format, TimeZone timeZone) { 178 if (value == null) 179 return null; 180 if (value instanceof XMLGregorianCalendar value2) 181 return value2.toXMLFormat(); 182 ZoneId zone = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 183 return (format == null ? CalendarFormat.ISO_OFFSET_DATE_TIME : format).format((Calendar)value, zone); 184 } 185 186 /** 187 * Formats a {@link TemporalAccessor} value using the supplied wire format. 188 * 189 * @param value The value to format. Must be a {@link Temporal} subtype. <jk>null</jk> returns <jk>null</jk>. 190 * @param sourceType The class metadata for the value (currently unused, kept for symmetry with the parse helper). 191 * @param format The temporal wire format. May be <jk>null</jk> (defaults to {@link TemporalFormat#DEFAULT}). 192 * @param timeZone The session time zone (used as the default zone when the value lacks zone info). 193 * @return The formatted value, or <jk>null</jk> if {@code value} is <jk>null</jk>. 194 */ 195 public static String formatTemporal(TemporalAccessor value, ClassMeta<?> sourceType, TemporalFormat format, TimeZone timeZone) { 196 if (value == null) 197 return null; 198 ZoneId zone = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 199 return (format == null ? TemporalFormat.DEFAULT : format).format(value, zone); 200 } 201 202 /** 203 * Formats a date/time value as an ISO date (date-only, for OpenAPI 'date' format). 204 * 205 * @param value The value to format. <jk>null</jk> returns <jk>null</jk>. 206 * @param type The class metadata. 207 * @param timeZone The session time zone. 208 * @return The ISO date string, or <jk>null</jk> if {@code value} is <jk>null</jk>. 209 */ 210 public static String formatAsDate(Object value, ClassMeta<?> type, TimeZone timeZone) { 211 if (value == null) 212 return null; 213 ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 214 if (value instanceof Calendar value2) { 215 ZonedDateTime zdt = (value2 instanceof GregorianCalendar value3) ? value3.toZonedDateTime() : value2.toInstant().atZone(value2.getTimeZone().toZoneId()); 216 return DateTimeFormatter.ISO_DATE.format(zdt); 217 } 218 if (value instanceof Date value2) 219 return DateTimeFormatter.ISO_DATE.format(value2.toInstant().atZone(zoneId)); 220 if (value instanceof Temporal value2) 221 return DateTimeFormatter.ISO_DATE.format(ZonedDateTime.from(new DefaultingTemporalAccessor(value2, zoneId))); 222 return value.toString(); 223 } 224 225 /** 226 * Formats a date/time value as an ISO date-time (for OpenAPI 'date-time' format). 227 * 228 * @param value The value to format. <jk>null</jk> returns <jk>null</jk>. 229 * @param type The class metadata. 230 * @param timeZone The session time zone. 231 * @return The ISO date-time string, or <jk>null</jk> if {@code value} is <jk>null</jk>. 232 */ 233 public static String formatAsDateTime(Object value, ClassMeta<?> type, TimeZone timeZone) { 234 if (value == null) 235 return null; 236 ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 237 if (value instanceof Calendar value2) { 238 ZonedDateTime zdt = (value2 instanceof GregorianCalendar value3) ? value3.toZonedDateTime() : value2.toInstant().atZone(value2.getTimeZone().toZoneId()); 239 return DateTimeFormatter.ISO_INSTANT.format(zdt.toInstant()); 240 } 241 if (value instanceof Date value2) 242 return DateTimeFormatter.ISO_INSTANT.format(value2.toInstant()); 243 if (value instanceof Temporal value2) { 244 ZonedDateTime zdt = ZonedDateTime.from(new DefaultingTemporalAccessor(value2, zoneId)); 245 return DateTimeFormatter.ISO_INSTANT.format(zdt.toInstant()); 246 } 247 return value.toString(); 248 } 249 250 //----------------------------------------------------------------------------------------------------------------- 251 // Parsing 252 //----------------------------------------------------------------------------------------------------------------- 253 254 /** 255 * Parses an ISO 8601 string into the specified date/time target type. 256 * 257 * <p> 258 * Dispatches to the appropriate per-type parser based on the target's {@link ClassMeta} category: 259 * {@link #parseDate parseDate}, {@link #parseCalendar parseCalendar}, {@link #parseTemporal parseTemporal}, 260 * {@link #parseDuration parseDuration}, or {@link #parsePeriod parsePeriod} — each using its default 261 * wire format. 262 * 263 * @param <T> The target type. 264 * @param iso8601 The ISO 8601 string to parse. <jk>null</jk> returns <jk>null</jk>. 265 * @param targetType The target class metadata. 266 * @param timeZone The session time zone (used for types that need a default zone). 267 * @return The parsed object, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target type is 268 * not a recognized date/time/duration/period type. 269 */ 270 public static <T> T parse(String iso8601, ClassMeta<T> targetType, TimeZone timeZone) { 271 if (iso8601 == null) 272 return null; 273 if (targetType.isDate()) 274 return parseDate(iso8601, targetType, DateFormat.ISO_LOCAL_DATE_TIME, timeZone); 275 if (targetType.isCalendar()) 276 return parseCalendar(iso8601, targetType, CalendarFormat.ISO_OFFSET_DATE_TIME, timeZone); 277 if (targetType.isTemporal()) 278 return (T) parseTemporal(iso8601, (ClassMeta<? extends Temporal>)(ClassMeta<?>)targetType, TemporalFormat.DEFAULT, timeZone); 279 if (targetType.isDuration()) 280 return (T) parseDuration(iso8601, DurationFormat.MILLIS); 281 if (targetType.isPeriod()) 282 return (T) parsePeriod(iso8601, PeriodFormat.ISO_8601); 283 return null; 284 } 285 286 /** 287 * Parses an ISO 8601 / numeric wire value into a {@link Duration}. 288 * 289 * <p> 290 * Handles ISO 8601 forms ({@code "PT1H"}, {@code "-PT30M"}, {@code "PT-6H"}, {@code "PT1.5S"}, …) as well 291 * as the bare-number wire forms produced by {@link DurationFormat#MILLIS}, {@link DurationFormat#SECONDS}, 292 * {@link DurationFormat#NANOS}, and {@link DurationFormat#HOCON} (e.g. {@code "500ms"}, {@code "1.5s"}). 293 * 294 * <p> 295 * The {@code formatHint} disambiguates bare integer strings that could be milliseconds, nanos, or seconds — 296 * for all other numeric or ISO forms the input is self-describing and the hint is ignored. 297 * 298 * @param iso8601 The wire value to parse. <jk>null</jk> or empty returns <jk>null</jk>. 299 * @param formatHint The duration wire format used to disambiguate bare integer inputs. May be <jk>null</jk> 300 * (defaults to {@link DurationFormat#MILLIS}). 301 * @return The parsed {@link Duration}, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or empty. 302 */ 303 public static Duration parseDuration(String iso8601, DurationFormat formatHint) { 304 if (ie(iso8601)) 305 return null; 306 String s = iso8601.trim(); 307 // Strip optional surrounding quotes 308 if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) 309 s = s.substring(1, s.length() - 1).trim(); 310 if (s.startsWith("PT-")) 311 s = "-PT" + s.substring(3); 312 // Number sniffing and explicit format hints. 313 if (isIntegerLiteral(s)) 314 return (formatHint == null ? DurationFormat.MILLIS : formatHint).parse(s); 315 if (isDecimalLiteral(s)) 316 return DurationFormat.SECONDS.parse(s); 317 if (isHoconLiteral(s)) 318 return DurationFormat.HOCON.parse(s); 319 // Use manual parser for full control (handles PTnH, PTnM, PTn.nS, -PTnH, PT-6H, etc.) 320 Duration d = parseDurationManual(s); 321 if (d != null) 322 return d; 323 // Fallback to standard parser 324 try { 325 return Duration.parse(s); 326 } catch (Exception e) { 327 throw toRex(e); 328 } 329 } 330 331 /** 332 * Parses an ISO 8601 / numeric wire value into a {@link Period}. 333 * 334 * <p> 335 * Handles standard ISO 8601 period strings ({@code "P1Y2M3D"}, …) as well as the bare-integer wire form 336 * produced by {@link PeriodFormat#DAYS}. 337 * 338 * @param value The wire value to parse. <jk>null</jk> or empty returns <jk>null</jk>. 339 * @param formatHint The period wire format used to interpret bare integers. May be <jk>null</jk> 340 * (defaults to {@link PeriodFormat#DAYS}). 341 * @return The parsed {@link Period}, or <jk>null</jk> if {@code value} is <jk>null</jk> or empty. 342 */ 343 public static Period parsePeriod(String value, PeriodFormat formatHint) { 344 if (ie(value)) 345 return null; 346 String s = value.trim(); 347 if (isIntegerLiteral(s)) 348 return (formatHint == null ? PeriodFormat.DAYS : formatHint).parse(s); 349 return Period.parse(s); 350 } 351 352 /** 353 * Parses an ISO 8601 wire value into a {@link Calendar} or {@link XMLGregorianCalendar}. 354 * 355 * <p> 356 * Dispatches by target type: 357 * <ul> 358 * <li>{@link XMLGregorianCalendar} targets always use {@link DatatypeFactory#newXMLGregorianCalendar(String)} 359 * and ignore {@code formatHint}. 360 * <li>{@link Calendar} / {@link GregorianCalendar} targets honor {@code formatHint} when set to anything 361 * other than {@link CalendarFormat#NOT_SET} or {@link CalendarFormat#ISO_OFFSET_DATE_TIME}; the default 362 * path uses content-based formatter autodetection. 363 * </ul> 364 * 365 * @param <T> The target type. 366 * @param iso8601 The wire value to parse. <jk>null</jk> returns <jk>null</jk>. 367 * @param targetType The target class metadata. Must represent a {@link Calendar} or {@link XMLGregorianCalendar}. 368 * @param formatHint The calendar wire format. May be <jk>null</jk> (treated as {@link CalendarFormat#NOT_SET}). 369 * @param timeZone The session time zone (used as the default zone when the wire value lacks zone info). 370 * @return The parsed value, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target is not a 371 * recognized calendar type. 372 */ 373 public static <T> T parseCalendar(String iso8601, ClassMeta<T> targetType, CalendarFormat formatHint, TimeZone timeZone) { 374 if (iso8601 == null) 375 return null; 376 Class<T> tc = targetType.inner(); 377 if (XMLGregorianCalendar.class.isAssignableFrom(tc)) 378 return (T) datatypeFactory.newXMLGregorianCalendar(iso8601); 379 if (! Calendar.class.isAssignableFrom(tc)) 380 return null; 381 ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 382 if (formatHint != null && formatHint != CalendarFormat.NOT_SET && formatHint != CalendarFormat.ISO_OFFSET_DATE_TIME) 383 return (T) formatHint.parse(iso8601, zoneId); 384 return (T) parseCalendarDefault(iso8601, zoneId); 385 } 386 387 /** 388 * Parses an ISO 8601 wire value into a {@link Date}. 389 * 390 * <p> 391 * Honors {@code formatHint} when set to anything other than {@link DateFormat#NOT_SET} or 392 * {@link DateFormat#ISO_LOCAL_DATE_TIME}; the default path uses content-based formatter autodetection. 393 * 394 * <p> 395 * Only direct {@link Date} targets are produced — {@code java.sql.*} subclasses fall through to 396 * <jk>null</jk>, matching the original dispatch. 397 * 398 * @param <T> The target type. 399 * @param iso8601 The wire value to parse. <jk>null</jk> returns <jk>null</jk>. 400 * @param targetType The target class metadata. Must represent {@link Date}. 401 * @param formatHint The date wire format. May be <jk>null</jk> (treated as {@link DateFormat#NOT_SET}). 402 * @param timeZone The session time zone (used as the default zone when the wire value lacks zone info). 403 * @return The parsed {@link Date}, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target is 404 * not exactly {@code Date}. 405 */ 406 public static <T> T parseDate(String iso8601, ClassMeta<T> targetType, DateFormat formatHint, TimeZone timeZone) { 407 if (iso8601 == null) 408 return null; 409 if (targetType.inner() != Date.class) 410 return null; 411 ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 412 if (formatHint != null && formatHint != DateFormat.NOT_SET && formatHint != DateFormat.ISO_LOCAL_DATE_TIME) 413 return (T) formatHint.parse(iso8601, zoneId); 414 return (T) parseDateDefault(iso8601, zoneId); 415 } 416 417 /** 418 * Parses an ISO 8601 wire value into the specified {@link Temporal} subtype. 419 * 420 * <p> 421 * Honors {@code formatHint} when set to anything other than {@link TemporalFormat#NOT_SET} or 422 * {@link TemporalFormat#DEFAULT}; the default path uses the per-subtype default formatter 423 * (e.g. {@link DateTimeFormatter#ISO_INSTANT} for {@link Instant}, {@link DateTimeFormatter#ISO_LOCAL_DATE} 424 * for {@link LocalDate}, …). 425 * 426 * @param <T> The target temporal subtype. 427 * @param iso8601 The wire value to parse. <jk>null</jk> returns <jk>null</jk>. 428 * @param targetType The target class metadata. Must represent a {@link Temporal} subtype. 429 * @param formatHint The temporal wire format. May be <jk>null</jk> (treated as {@link TemporalFormat#NOT_SET}). 430 * @param timeZone The session time zone (used as the default zone when the wire value lacks zone info). 431 * @return The parsed value, or <jk>null</jk> if {@code iso8601} is <jk>null</jk> or the target is not a 432 * {@link Temporal} subtype. 433 */ 434 public static <T> T parseTemporal(String iso8601, ClassMeta<T> targetType, TemporalFormat formatHint, TimeZone timeZone) { 435 if (iso8601 == null) 436 return null; 437 Class<T> tc = targetType.inner(); 438 if (! Temporal.class.isAssignableFrom(tc)) 439 return null; 440 ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 441 Class<? extends Temporal> ttc = (Class<? extends Temporal>) tc; 442 if (formatHint != null && formatHint != TemporalFormat.NOT_SET && formatHint != TemporalFormat.DEFAULT) 443 return (T) formatHint.parse(iso8601, ttc, zoneId); 444 return (T) parseTemporalDefault(iso8601, ttc, zoneId); 445 } 446 447 /** 448 * Hand-written state machine that parses the {@code PT}-prefixed body as a strict, gapless sequence of 449 * {@code number[whitespace]unit} components ({@code H}/{@code M}/{@code S}, case-insensitive). 450 * 451 * <p> 452 * Whitespace is permitted between a number and its unit and between/around components, but every 453 * non-whitespace character must belong to a valid component. The parser rejects (by returning <jk>null</jk>, 454 * the same signal used for any input it cannot handle) when it encounters stray characters, a malformed 455 * number (e.g. more than one decimal point, or a trailing dot with no fraction), or a number without a 456 * trailing unit. On rejection {@link #parseDuration} falls through to {@link Duration#parse(CharSequence)}, 457 * which throws for genuinely invalid values. Numeric conversion uses the floating-point expression 458 * {@code (long)(value * 1_000_000_000)} to preserve historical rounding for well-formed inputs. 459 * 460 * @param s The candidate duration string (already trimmed/dequoted/PT-normalized by {@link #parseDuration}). 461 * @return The parsed {@link Duration}, or <jk>null</jk> if the string is too short, lacks a {@code PT}/{@code pt} 462 * prefix, contains no component, or contains any stray/malformed content. 463 */ 464 @SuppressWarnings({ 465 "java:S3776" // Hand-written ISO-8601 duration lexer/state-machine; decomposing the char-by-char scan would obscure the parse invariants without changing behavior. 466 }) 467 private static Duration parseDurationManual(String s) { 468 if (s == null || s.length() < 3) 469 return null; 470 boolean neg = s.startsWith("-"); 471 String s2 = neg ? s.substring(1) : s; 472 if (!s2.startsWith("PT") && !s2.startsWith("pt")) 473 return null; 474 s2 = s2.substring(2); 475 long totalNanos = 0; 476 boolean found = false; 477 int len = s2.length(); 478 int i = 0; 479 while (i < len) { 480 // Whitespace between/around components is skipped; any other non-component character is rejected. 481 if (isRegexWhitespace(s2.charAt(i))) { 482 i++; 483 continue; 484 } 485 // A component must begin with a digit; any other character is stray junk → reject. 486 if (!isAsciiDigit(s2.charAt(i))) 487 return null; 488 int start = i; 489 while (i < len && isAsciiDigit(s2.charAt(i))) // Integer part. 490 i++; 491 // Optional fraction: a single '.' that MUST be followed by at least one digit. 492 if (i < len && s2.charAt(i) == '.') { 493 i++; 494 if (i >= len || !isAsciiDigit(s2.charAt(i))) 495 return null; // Trailing dot with no fraction → malformed. 496 while (i < len && isAsciiDigit(s2.charAt(i))) 497 i++; 498 } 499 int numEnd = i; 500 while (i < len && isRegexWhitespace(s2.charAt(i))) // Optional whitespace between number and unit. 501 i++; 502 // A unit designator is required; a number without one (or followed by stray chars) is malformed. 503 if (i >= len || !isDurationUnit(s2.charAt(i))) 504 return null; 505 found = true; 506 double v = Double.parseDouble(s2.substring(start, numEnd)); 507 long nanos = (long)(v * 1_000_000_000); 508 char unit = Character.toUpperCase(s2.charAt(i)); 509 switch (unit) { 510 case 'H': totalNanos += nanos * 3600; break; 511 case 'M': totalNanos += nanos * 60; break; 512 case 'S': totalNanos += nanos; break; 513 default: break; // Unreachable; isDurationUnit(char) already restricted the unit to H, M or S. 514 } 515 i++; // Consume the unit. 516 } 517 if (!found) 518 return null; 519 Duration d = Duration.ofNanos(totalNanos); 520 return neg ? d.negated() : d; 521 } 522 523 private static boolean isAsciiDigit(char c) { 524 return c >= '0' && c <= '9'; 525 } 526 527 /** 528 * Matches the regex {@code \s} character class (without the {@code UNICODE_CHARACTER_CLASS} flag). 529 */ 530 private static boolean isRegexWhitespace(char c) { 531 return c == ' ' || c == '\t' || c == '\n' || c == '\u000B' || c == '\f' || c == '\r'; 532 } 533 534 private static boolean isDurationUnit(char c) { 535 return c == 'H' || c == 'h' || c == 'M' || c == 'm' || c == 'S' || c == 's'; 536 } 537 538 /** 539 * State-machine equivalent of {@code ^[+-]?\d++$} — an optional sign followed by one or more ASCII digits. 540 * 541 * @param s The string to test. 542 * @return <jk>true</jk> if the entire string is an optionally-signed integer literal. 543 */ 544 private static boolean isIntegerLiteral(String s) { 545 int len = s.length(); 546 int i = 0; 547 if (i < len && (s.charAt(i) == '+' || s.charAt(i) == '-')) 548 i++; 549 if (i == len) 550 return false; 551 for (int k = i; k < len; k++) 552 if (!isAsciiDigit(s.charAt(k))) 553 return false; 554 return true; 555 } 556 557 /** 558 * State-machine equivalent of {@code ^[+-]?\d++\.\d++$} — an optionally-signed decimal with digits on both 559 * sides of a single dot. 560 * 561 * @param s The string to test. 562 * @return <jk>true</jk> if the entire string is an optionally-signed decimal literal. 563 */ 564 private static boolean isDecimalLiteral(String s) { 565 int len = s.length(); 566 int i = 0; 567 if (i < len && (s.charAt(i) == '+' || s.charAt(i) == '-')) 568 i++; 569 int intStart = i; 570 while (i < len && isAsciiDigit(s.charAt(i))) 571 i++; 572 if (i == intStart) 573 return false; 574 if (i >= len || s.charAt(i) != '.') 575 return false; 576 i++; 577 int fracStart = i; 578 while (i < len && isAsciiDigit(s.charAt(i))) 579 i++; 580 if (i == fracStart) 581 return false; 582 return i == len; 583 } 584 585 /** 586 * State-machine equivalent of {@code ^[+-]?\d++(?:\.\d++)?(?:ns|us|ms|s|m|h|d)$} — an optionally-signed number 587 * (with optional fraction) followed by a HOCON time unit. 588 * 589 * @param s The string to test. 590 * @return <jk>true</jk> if the entire string is an optionally-signed HOCON duration literal. 591 */ 592 private static boolean isHoconLiteral(String s) { 593 int len = s.length(); 594 int i = 0; 595 if (i < len && (s.charAt(i) == '+' || s.charAt(i) == '-')) 596 i++; 597 int intStart = i; 598 while (i < len && isAsciiDigit(s.charAt(i))) 599 i++; 600 if (i == intStart) 601 return false; 602 // Optional fraction consumed only when '.' is followed by at least one digit; otherwise left for the unit. 603 if (i < len && s.charAt(i) == '.' && i + 1 < len && isAsciiDigit(s.charAt(i + 1))) { 604 i++; 605 while (i < len && isAsciiDigit(s.charAt(i))) 606 i++; 607 } 608 String unit = s.substring(i); 609 return eqa(unit, "ns", "us", "ms", "s", "m", "h", "d"); 610 } 611 612 private static Calendar parseCalendarDefault(String iso8601, ZoneId zoneId) { 613 var formatter = selectParserFormatter(iso8601); 614 var ta = new DefaultingTemporalAccessor(formatter.parse(iso8601), zoneId); 615 return GregorianCalendar.from(ZonedDateTime.from(ta)); 616 } 617 618 private static Date parseDateDefault(String iso8601, ZoneId zoneId) { 619 var formatter = selectParserFormatter(iso8601); 620 var ta = new DefaultingTemporalAccessor(formatter.parse(iso8601), zoneId); 621 return Date.from(ZonedDateTime.from(ta).toInstant()); 622 } 623 624 private static <T extends Temporal> T parseTemporalDefault(String iso8601, Class<T> tc, ZoneId zoneId) { 625 ZoneId offset = (tc == Instant.class) ? Z : zoneId; 626 var formatter = getFormatterForType(tc); 627 var ta = new DefaultingTemporalAccessor(formatter.parse(iso8601), offset); 628 629 try { 630 var parseMethod = info(tc).getPublicMethod( 631 x -> x.isStatic() 632 && x.isNotDeprecated() 633 && x.hasName("from") 634 && x.hasReturnType(tc) 635 && x.hasParameterTypes(TemporalAccessor.class) 636 ).map(MethodInfo::inner).orElse(null); 637 638 if (parseMethod != null) 639 return (T) parseMethod.invoke(null, ta); 640 } catch (IllegalAccessException | InvocationTargetException e) { 641 throw toRex(e); 642 } 643 644 throw iaex("Cannot parse ISO 8601 string into type: %s", tc.getName()); 645 } 646 647 private static DateTimeFormatter getFormatterForType(Class<?> tc) { 648 var f = DEFAULT_FORMATTERS.get(tc); 649 return or(f, DateTimeFormatter.ISO_INSTANT); 650 } 651 652 /** 653 * Auto-detects the appropriate parser formatter based on the string content. 654 */ 655 private static DateTimeFormatter selectParserFormatter(String iso8601) { 656 boolean hasTime = iso8601.contains("T"); 657 boolean hasZone = iso8601.endsWith("Z") || iso8601.contains("+") 658 || (iso8601.length() > 10 && iso8601.lastIndexOf('-') > 10); 659 if (hasTime && hasZone) 660 return DateTimeFormatter.ISO_OFFSET_DATE_TIME; 661 if (hasTime) 662 return DateTimeFormatter.ISO_LOCAL_DATE_TIME; 663 if (hasZone) 664 return DateTimeFormatter.ISO_DATE; 665 return DateTimeFormatter.ISO_LOCAL_DATE; 666 } 667 668 /** 669 * Converts epoch milliseconds to the target date/time type. 670 * 671 * @param <T> The target type. 672 * @param epochMillis The epoch milliseconds value. 673 * @param targetType The target class metadata. 674 * @param timeZone The session time zone. 675 * @return The converted date/time object, or <jk>null</jk> if the target type is not a recognized date/time type. 676 */ 677 public static <T> T fromEpochMillis(long epochMillis, ClassMeta<T> targetType, TimeZone timeZone) { 678 Class<T> tc = targetType.inner(); 679 ZoneId zoneId = timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault(); 680 Instant instant = Instant.ofEpochMilli(epochMillis); 681 682 if (tc == Instant.class) return (T) instant; 683 if (tc == ZonedDateTime.class) return (T) instant.atZone(zoneId); 684 if (tc == OffsetDateTime.class) return (T) instant.atZone(zoneId).toOffsetDateTime(); 685 if (tc == LocalDateTime.class) return (T) instant.atZone(zoneId).toLocalDateTime(); 686 if (tc == LocalDate.class) return (T) instant.atZone(zoneId).toLocalDate(); 687 if (tc == LocalTime.class) return (T) instant.atZone(zoneId).toLocalTime(); 688 if (tc == OffsetTime.class) return (T) instant.atZone(zoneId).toOffsetDateTime().toOffsetTime(); 689 if (tc == Date.class) return (T) Date.from(instant); 690 if (Calendar.class.isAssignableFrom(tc)) { 691 var cal = GregorianCalendar.from(instant.atZone(zoneId)); 692 return (T) cal; 693 } 694 if (XMLGregorianCalendar.class.isAssignableFrom(tc)) { 695 var gc = GregorianCalendar.from(instant.atZone(zoneId)); 696 return (T) datatypeFactory.newXMLGregorianCalendar(gc); 697 } 698 699 return null; 700 } 701}