001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet.converter; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.ThreadSafe; 023import java.lang.reflect.Type; 024import java.math.BigDecimal; 025import java.math.BigInteger; 026import java.time.Instant; 027import java.time.LocalDate; 028import java.time.LocalDateTime; 029import java.time.LocalTime; 030import java.time.ZoneId; 031import java.time.format.DateTimeParseException; 032import java.util.Collections; 033import java.util.Currency; 034import java.util.Date; 035import java.util.HashSet; 036import java.util.Locale; 037import java.util.Optional; 038import java.util.Set; 039import java.util.TimeZone; 040import java.util.UUID; 041 042import static com.soklet.Utilities.trimAggressivelyToNull; 043import static java.lang.String.format; 044 045/** 046 * Non-instantiable utility class that exists to vend a set of default {@link ValueConverter} instances via {@link #defaultValueConverters()}. 047 * <p> 048 * Default converters are documented at <a href="https://www.soklet.com/docs/value-conversions#default-conversions">https://www.soklet.com/docs/value-conversions#default-conversions</a>. 049 * 050 * @author <a href="https://www.revetkn.com">Mark Allen</a> 051 */ 052@ThreadSafe 053public final class ValueConverters { 054 @NonNull 055 private static final Set<@NonNull ValueConverter<?, ?>> DEFAULT_VALUE_CONVERTERS; 056 057 static { 058 DEFAULT_VALUE_CONVERTERS = Collections.unmodifiableSet(createDefaultValueConverters()); 059 } 060 061 private ValueConverters() { 062 // Cannot instantiate 063 } 064 065 /** 066 * Vends the immutable system-default set of {@link ValueConverter} instances. 067 * <p> 068 * The returned set is guaranteed to be a JVM-wide singleton. 069 * 070 * @return the default set of converters 071 */ 072 @NonNull 073 public static Set<@NonNull ValueConverter<?, ?>> defaultValueConverters() { 074 return DEFAULT_VALUE_CONVERTERS; 075 } 076 077 @NonNull 078 private static Set<@NonNull ValueConverter<?, ?>> createDefaultValueConverters() { 079 Set<@NonNull ValueConverter<?, ?>> defaultValueConverters = new HashSet<>(); 080 081 // Primitives 082 defaultValueConverters.add(new StringToIntegerValueConverter()); 083 defaultValueConverters.add(new StringToLongValueConverter()); 084 defaultValueConverters.add(new StringToDoubleValueConverter()); 085 defaultValueConverters.add(new StringToFloatValueConverter()); 086 defaultValueConverters.add(new StringToByteValueConverter()); 087 defaultValueConverters.add(new StringToShortValueConverter()); 088 defaultValueConverters.add(new StringToCharacterValueConverter()); 089 defaultValueConverters.add(new StringToBooleanValueConverter()); 090 defaultValueConverters.add(new StringToBigIntegerValueConverter()); 091 defaultValueConverters.add(new StringToBigDecimalValueConverter()); 092 defaultValueConverters.add(new StringToNumberValueConverter()); 093 defaultValueConverters.add(new StringToUuidValueConverter()); 094 defaultValueConverters.add(new StringToInstantValueConverter()); 095 defaultValueConverters.add(new StringToDateValueConverter()); 096 defaultValueConverters.add(new StringToLocalDateValueConverter()); 097 defaultValueConverters.add(new StringToLocalTimeValueConverter()); 098 defaultValueConverters.add(new StringToLocalDateTimeValueConverter()); 099 defaultValueConverters.add(new StringToZoneIdValueConverter()); 100 defaultValueConverters.add(new StringToTimeZoneValueConverter()); 101 defaultValueConverters.add(new StringToLocaleValueConverter()); 102 defaultValueConverters.add(new StringToCurrencyValueConverter()); 103 104 return defaultValueConverters; 105 } 106 107 // Primitives 108 109 @ThreadSafe 110 private static final class StringToIntegerValueConverter extends FromStringValueConverter<Integer> { 111 @Override 112 @NonNull 113 public Optional<Integer> performConversion(@Nullable String from) throws Exception { 114 if (from == null) 115 return Optional.empty(); 116 117 return Optional.of(Integer.parseInt(from)); 118 } 119 } 120 121 @ThreadSafe 122 private static final class StringToLongValueConverter extends FromStringValueConverter<Long> { 123 @Override 124 @NonNull 125 public Optional<Long> performConversion(@Nullable String from) throws Exception { 126 if (from == null) 127 return Optional.empty(); 128 129 return Optional.of(Long.parseLong(from)); 130 } 131 } 132 133 @ThreadSafe 134 private static final class StringToDoubleValueConverter extends FromStringValueConverter<Double> { 135 @Override 136 @NonNull 137 public Optional<Double> performConversion(@Nullable String from) throws Exception { 138 if (from == null) 139 return Optional.empty(); 140 141 return Optional.of(Double.parseDouble(from)); 142 } 143 } 144 145 @ThreadSafe 146 private static final class StringToFloatValueConverter extends FromStringValueConverter<Float> { 147 @Override 148 @NonNull 149 public Optional<Float> performConversion(@Nullable String from) throws Exception { 150 if (from == null) 151 return Optional.empty(); 152 153 return Optional.of(Float.parseFloat(from)); 154 } 155 } 156 157 @ThreadSafe 158 private static final class StringToByteValueConverter extends FromStringValueConverter<Byte> { 159 @Override 160 @NonNull 161 public Optional<Byte> performConversion(@Nullable String from) throws Exception { 162 if (from == null) 163 return Optional.empty(); 164 165 return Optional.of(Byte.parseByte(from)); 166 } 167 } 168 169 @ThreadSafe 170 private static final class StringToShortValueConverter extends FromStringValueConverter<Short> { 171 @Override 172 @NonNull 173 public Optional<Short> performConversion(@Nullable String from) throws Exception { 174 if (from == null) 175 return Optional.empty(); 176 177 return Optional.of(Short.parseShort(from)); 178 } 179 } 180 181 @ThreadSafe 182 private static final class StringToCharacterValueConverter extends FromStringValueConverter<Character> { 183 @NonNull 184 @Override 185 public Optional<Character> performConversion(@Nullable String from) throws Exception { 186 if (from == null) 187 return Optional.empty(); 188 189 String trimmedFrom = trimAggressivelyToNull(from); 190 191 // Special handling for all-whitespace. 192 // If there is at least one space, return ' ' 193 if (from.length() > 0 && trimmedFrom == null) 194 return Optional.of(' '); 195 196 if (trimmedFrom.length() != 1) 197 throw new ValueConversionException(format( 198 "Unable to convert %s value '%s' to %s. Reason: '%s' is not a single-character String.", getFromType(), trimmedFrom, 199 getToType(), from), getFromType(), from, getToType()); 200 201 return Optional.of(trimmedFrom.charAt(0)); 202 } 203 204 @NonNull 205 @Override 206 protected Boolean shouldTrimFromValues() { 207 // Special handling: we want to handle trimming ourselves 208 return false; 209 } 210 211 @NonNull 212 @Override 213 public Type getFromType() { 214 return String.class; 215 } 216 217 @NonNull 218 @Override 219 public Type getToType() { 220 return Character.class; 221 } 222 } 223 224 @ThreadSafe 225 private static final class StringToBooleanValueConverter extends FromStringValueConverter<Boolean> { 226 @Override 227 @NonNull 228 public Optional<Boolean> performConversion(@Nullable String from) throws Exception { 229 if (from == null) 230 return Optional.empty(); 231 232 boolean isTrue = "true".equalsIgnoreCase(from); 233 234 if (isTrue) 235 return Optional.of(true); 236 237 boolean isFalse = "false".equalsIgnoreCase(from); 238 239 if (isFalse) 240 return Optional.of(false); 241 242 throw new IllegalArgumentException(format("'%s' is not a valid boolean value", from)); 243 } 244 } 245 246 // Nonprimitives 247 248 @ThreadSafe 249 private static final class StringToBigIntegerValueConverter extends FromStringValueConverter<BigInteger> { 250 @Override 251 @NonNull 252 public Optional<BigInteger> performConversion(@Nullable String from) throws Exception { 253 if (from == null) 254 return Optional.empty(); 255 256 return Optional.of(new BigInteger(from)); 257 } 258 } 259 260 @ThreadSafe 261 private static final class StringToBigDecimalValueConverter extends FromStringValueConverter<BigDecimal> { 262 @Override 263 @NonNull 264 public Optional<BigDecimal> performConversion(@Nullable String from) throws Exception { 265 if (from == null) 266 return Optional.empty(); 267 268 return Optional.of(new BigDecimal(from)); 269 } 270 } 271 272 @ThreadSafe 273 private static final class StringToNumberValueConverter extends FromStringValueConverter<Number> { 274 @Override 275 @NonNull 276 public Optional<Number> performConversion(@Nullable String from) throws Exception { 277 if (from == null) 278 return Optional.empty(); 279 280 return Optional.of(new BigDecimal(from)); 281 } 282 } 283 284 @ThreadSafe 285 private static final class StringToUuidValueConverter extends FromStringValueConverter<UUID> { 286 @Override 287 @NonNull 288 public Optional<UUID> performConversion(@Nullable String from) throws Exception { 289 if (from == null) 290 return Optional.empty(); 291 292 return Optional.of(UUID.fromString(from)); 293 } 294 } 295 296 @ThreadSafe 297 private static final class StringToDateValueConverter extends FromStringValueConverter<Date> { 298 @Override 299 @NonNull 300 public Optional<Date> performConversion(@Nullable String from) throws Exception { 301 if (from == null) 302 return Optional.empty(); 303 304 try { 305 return Optional.of(Date.from(Instant.parse(from))); 306 } catch (DateTimeParseException ignored) { 307 return Optional.of(new Date(Long.parseLong(from))); 308 } 309 } 310 } 311 312 @ThreadSafe 313 private static final class StringToInstantValueConverter extends FromStringValueConverter<Instant> { 314 @Override 315 @NonNull 316 public Optional<Instant> performConversion(@Nullable String from) throws Exception { 317 if (from == null) 318 return Optional.empty(); 319 320 try { 321 return Optional.of(Instant.parse(from)); 322 } catch (Exception ignored) { 323 return Optional.of(Instant.ofEpochMilli(Long.parseLong(from))); 324 } 325 } 326 } 327 328 @ThreadSafe 329 private static final class StringToLocalDateValueConverter extends FromStringValueConverter<LocalDate> { 330 @Override 331 @NonNull 332 public Optional<LocalDate> performConversion(@Nullable String from) throws Exception { 333 if (from == null) 334 return Optional.empty(); 335 336 return Optional.of(LocalDate.parse(from)); 337 } 338 } 339 340 @ThreadSafe 341 private static final class StringToLocalTimeValueConverter extends FromStringValueConverter<LocalTime> { 342 @Override 343 @NonNull 344 public Optional<LocalTime> performConversion(@Nullable String from) throws Exception { 345 if (from == null) 346 return Optional.empty(); 347 348 return Optional.of(LocalTime.parse(from)); 349 } 350 } 351 352 @ThreadSafe 353 private static final class StringToLocalDateTimeValueConverter extends FromStringValueConverter<LocalDateTime> { 354 @Override 355 @NonNull 356 public Optional<LocalDateTime> performConversion(@Nullable String from) throws Exception { 357 if (from == null) 358 return Optional.empty(); 359 360 return Optional.of(LocalDateTime.parse(from)); 361 } 362 } 363 364 @ThreadSafe 365 private static final class StringToZoneIdValueConverter extends FromStringValueConverter<ZoneId> { 366 @Override 367 @NonNull 368 public Optional<ZoneId> performConversion(@Nullable String from) throws Exception { 369 if (from == null) 370 return Optional.empty(); 371 372 return Optional.of(ZoneId.of(from)); 373 } 374 } 375 376 @ThreadSafe 377 private static final class StringToTimeZoneValueConverter extends FromStringValueConverter<TimeZone> { 378 @Override 379 @NonNull 380 public Optional<TimeZone> performConversion(@Nullable String from) throws Exception { 381 if (from == null) 382 return Optional.empty(); 383 384 // Use ZoneId.of since it will throw an exception if the format is invalid. 385 // TimeZone.getTimeZone() returns GMT for invalid formats, which is not the behavior we want 386 return Optional.of(TimeZone.getTimeZone(ZoneId.of(from))); 387 } 388 } 389 390 @ThreadSafe 391 private static final class StringToLocaleValueConverter extends FromStringValueConverter<Locale> { 392 @Override 393 @NonNull 394 public Optional<Locale> performConversion(@Nullable String from) throws Exception { 395 if (from == null) 396 return Optional.empty(); 397 398 return Optional.of(new Locale.Builder().setLanguageTag(from).build()); 399 } 400 } 401 402 @ThreadSafe 403 private static final class StringToCurrencyValueConverter extends FromStringValueConverter<Currency> { 404 @Override 405 @NonNull 406 public Optional<Currency> performConversion(@Nullable String from) throws Exception { 407 if (from == null) 408 return Optional.empty(); 409 410 return Optional.of(Currency.getInstance(from)); 411 } 412 } 413}