001/* 002 * Copyright 2015-2022 Transmogrify LLC, 2022-2023 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.pyranid; 018 019import javax.annotation.Nonnull; 020import javax.annotation.Nullable; 021import javax.annotation.concurrent.ThreadSafe; 022import java.beans.BeanInfo; 023import java.beans.Introspector; 024import java.beans.PropertyDescriptor; 025import java.lang.reflect.Field; 026import java.lang.reflect.Method; 027import java.lang.reflect.Parameter; 028import java.lang.reflect.RecordComponent; 029import java.math.BigDecimal; 030import java.math.BigInteger; 031import java.sql.ResultSet; 032import java.sql.ResultSetMetaData; 033import java.sql.SQLException; 034import java.sql.Timestamp; 035import java.time.Instant; 036import java.time.LocalDate; 037import java.time.LocalDateTime; 038import java.time.LocalTime; 039import java.time.OffsetDateTime; 040import java.time.OffsetTime; 041import java.time.ZoneId; 042import java.util.ArrayList; 043import java.util.Calendar; 044import java.util.Date; 045import java.util.HashMap; 046import java.util.HashSet; 047import java.util.List; 048import java.util.Locale; 049import java.util.Map; 050import java.util.Optional; 051import java.util.Set; 052import java.util.TimeZone; 053import java.util.UUID; 054import java.util.concurrent.ConcurrentHashMap; 055 056import static java.lang.String.format; 057import static java.util.Arrays.asList; 058import static java.util.Collections.unmodifiableMap; 059import static java.util.Collections.unmodifiableSet; 060import static java.util.Locale.ENGLISH; 061import static java.util.Objects.requireNonNull; 062import static java.util.stream.Collectors.joining; 063import static java.util.stream.Collectors.toSet; 064 065/** 066 * Basic implementation of {@link ResultSetMapper}. 067 * 068 * @author <a href="https://www.revetkn.com">Mark Allen</a> 069 * @since 1.0.0 070 */ 071@ThreadSafe 072public class DefaultResultSetMapper implements ResultSetMapper { 073 @Nonnull 074 private final DatabaseType databaseType; 075 @Nonnull 076 private final ZoneId timeZone; 077 @Nonnull 078 private final Calendar timeZoneCalendar; 079 @Nonnull 080 private final Map<Class<?>, Map<String, Set<String>>> columnLabelAliasesByPropertyNameCache = 081 new ConcurrentHashMap<>(); 082 083 /** 084 * Creates a {@code ResultSetMapper} with default configuration. 085 */ 086 public DefaultResultSetMapper() { 087 this(null, null); 088 } 089 090 /** 091 * Creates a {@code ResultSetMapper} for the given {@code databaseType}. 092 * 093 * @param databaseType the type of database we're working with 094 */ 095 public DefaultResultSetMapper(@Nullable DatabaseType databaseType) { 096 this(databaseType, null); 097 } 098 099 /** 100 * Creates a {@code ResultSetMapper} for the given {@code timeZone}. 101 * 102 * @param timeZone the timezone to use when working with {@link java.sql.Timestamp} and similar values 103 */ 104 public DefaultResultSetMapper(@Nullable ZoneId timeZone) { 105 this(null, timeZone); 106 } 107 108 /** 109 * Creates a {@code ResultSetMapper} for the given {@code databaseType} and {@code timeZone}. 110 * 111 * @param databaseType the type of database we're working with 112 * @param timeZone the timezone to use when working with {@link java.sql.Timestamp} and similar values 113 * @since 1.0.15 114 */ 115 public DefaultResultSetMapper(@Nullable DatabaseType databaseType, 116 @Nullable ZoneId timeZone) { 117 this.databaseType = databaseType == null ? DatabaseType.GENERIC : databaseType; 118 this.timeZone = timeZone == null ? ZoneId.systemDefault() : timeZone; 119 this.timeZoneCalendar = Calendar.getInstance(TimeZone.getTimeZone(this.timeZone)); 120 } 121 122 @Override 123 @Nonnull 124 public <T> Optional<T> map(@Nonnull StatementContext<T> statementContext, 125 @Nonnull ResultSet resultSet, 126 @Nonnull Class<T> resultSetRowType, 127 @Nonnull InstanceProvider instanceProvider) { 128 requireNonNull(statementContext); 129 requireNonNull(resultSet); 130 requireNonNull(resultSetRowType); 131 requireNonNull(instanceProvider); 132 133 try { 134 StandardTypeResult<T> standardTypeResult = mapResultSetToStandardType(resultSet, resultSetRowType); 135 136 if (standardTypeResult.isStandardType()) 137 return standardTypeResult.getValue(); 138 139 if (resultSetRowType.isRecord()) 140 return Optional.ofNullable((T) mapResultSetToRecord((StatementContext<? extends Record>) statementContext, resultSet, instanceProvider)); 141 142 return Optional.ofNullable(mapResultSetToBean(statementContext, resultSet, instanceProvider)); 143 } catch (DatabaseException e) { 144 throw e; 145 } catch (Exception e) { 146 throw new DatabaseException(format("Unable to map JDBC %s to %s", ResultSet.class.getSimpleName(), resultSetRowType), 147 e); 148 } 149 } 150 151 /** 152 * Attempts to map the current {@code resultSet} row to an instance of {@code resultClass} using one of the 153 * "out-of-the-box" types (primitives, common types like {@link UUID}, etc. 154 * <p> 155 * This does not attempt to map to a user-defined JavaBean - see {@link #mapResultSetToBean(StatementContext, ResultSet, InstanceProvider)} for 156 * that functionality. 157 * 158 * @param <T> result instance type token 159 * @param resultSet provides raw row data to pull from 160 * @param resultClass the type of instance to map to 161 * @return the result of the mapping 162 * @throws Exception if an error occurs during mapping 163 */ 164 @SuppressWarnings({"unchecked", "rawtypes"}) 165 @Nonnull 166 protected <T> StandardTypeResult<T> mapResultSetToStandardType(@Nonnull ResultSet resultSet, 167 @Nonnull Class<T> resultClass) throws Exception { 168 requireNonNull(resultSet); 169 requireNonNull(resultClass); 170 171 Object value = null; 172 boolean standardType = true; 173 174 if (resultClass.isAssignableFrom(Byte.class) || resultClass.isAssignableFrom(byte.class)) { 175 value = resultSet.getByte(1); 176 } else if (resultClass.isAssignableFrom(Short.class) || resultClass.isAssignableFrom(short.class)) { 177 value = resultSet.getShort(1); 178 } else if (resultClass.isAssignableFrom(Integer.class) || resultClass.isAssignableFrom(int.class)) { 179 value = resultSet.getInt(1); 180 } else if (resultClass.isAssignableFrom(Long.class) || resultClass.isAssignableFrom(long.class)) { 181 value = resultSet.getLong(1); 182 } else if (resultClass.isAssignableFrom(Float.class) || resultClass.isAssignableFrom(float.class)) { 183 value = resultSet.getFloat(1); 184 } else if (resultClass.isAssignableFrom(Double.class) || resultClass.isAssignableFrom(double.class)) { 185 value = resultSet.getDouble(1); 186 } else if (resultClass.isAssignableFrom(Boolean.class) || resultClass.isAssignableFrom(boolean.class)) { 187 value = resultSet.getBoolean(1); 188 } else if (resultClass.isAssignableFrom(Character.class) || resultClass.isAssignableFrom(char.class)) { 189 String string = resultSet.getString(1); 190 191 if (string != null) 192 if (string.length() == 1) 193 value = string.charAt(0); 194 else 195 throw new DatabaseException(format("Cannot map String value '%s' to %s", resultClass.getSimpleName())); 196 } else if (resultClass.isAssignableFrom(String.class)) { 197 value = resultSet.getString(1); 198 } else if (resultClass.isAssignableFrom(byte[].class)) { 199 value = resultSet.getBytes(1); 200 } else if (resultClass.isAssignableFrom(Enum.class)) { 201 value = Enum.valueOf((Class) resultClass, resultSet.getString(1)); 202 } else if (resultClass.isAssignableFrom(UUID.class)) { 203 String string = resultSet.getString(1); 204 205 if (string != null) 206 value = UUID.fromString(string); 207 } else if (resultClass.isAssignableFrom(BigDecimal.class)) { 208 value = resultSet.getBigDecimal(1); 209 } else if (resultClass.isAssignableFrom(BigInteger.class)) { 210 BigDecimal bigDecimal = resultSet.getBigDecimal(1); 211 212 if (bigDecimal != null) 213 value = bigDecimal.toBigInteger(); 214 } else if (resultClass.isAssignableFrom(Date.class)) { 215 value = resultSet.getTimestamp(1, getTimeZoneCalendar()); 216 } else if (resultClass.isAssignableFrom(Instant.class)) { 217 Timestamp timestamp = resultSet.getTimestamp(1, getTimeZoneCalendar()); 218 219 if (timestamp != null) 220 value = timestamp.toInstant(); 221 } else if (resultClass.isAssignableFrom(LocalDate.class)) { 222 value = resultSet.getObject(1); // DATE 223 } else if (resultClass.isAssignableFrom(LocalTime.class)) { 224 value = resultSet.getObject(1); // TIME 225 } else if (resultClass.isAssignableFrom(LocalDateTime.class)) { 226 value = resultSet.getObject(1); // TIMESTAMP 227 } else if (resultClass.isAssignableFrom(OffsetTime.class)) { 228 value = resultSet.getObject(1); // TIME WITH TIMEZONE 229 } else if (resultClass.isAssignableFrom(OffsetDateTime.class)) { 230 value = resultSet.getObject(1); // TIMESTAMP WITH TIMEZONE 231 } else if (resultClass.isAssignableFrom(java.sql.Date.class)) { 232 value = resultSet.getDate(1, getTimeZoneCalendar()); 233 } else if (resultClass.isAssignableFrom(ZoneId.class)) { 234 String zoneId = resultSet.getString(1); 235 236 if (zoneId != null) 237 value = ZoneId.of(zoneId); 238 } else if (resultClass.isAssignableFrom(TimeZone.class)) { 239 String timeZone = resultSet.getString(1); 240 241 if (timeZone != null) 242 value = TimeZone.getTimeZone(timeZone); 243 } else if (resultClass.isAssignableFrom(Locale.class)) { 244 String locale = resultSet.getString(1); 245 246 if (locale != null) 247 value = Locale.forLanguageTag(locale); 248 } else if (resultClass.isEnum()) { 249 value = extractEnumValue(resultClass, resultSet.getObject(1)); 250 251 // TODO: revisit java.sql.* handling 252 253 // } else if (resultClass.isAssignableFrom(java.sql.Blob.class)) { 254 // value = resultSet.getBlob(1); 255 // } else if (resultClass.isAssignableFrom(java.sql.Clob.class)) { 256 // value = resultSet.getClob(1); 257 // } else if (resultClass.isAssignableFrom(java.sql.Clob.class)) { 258 // value = resultSet.getClob(1); 259 260 } else { 261 standardType = false; 262 } 263 264 if (standardType) { 265 int columnCount = resultSet.getMetaData().getColumnCount(); 266 267 if (columnCount != 1) { 268 List<String> columnLabels = new ArrayList<>(columnCount); 269 270 for (int i = 1; i <= columnCount; ++i) 271 columnLabels.add(resultSet.getMetaData().getColumnLabel(i)); 272 273 throw new DatabaseException(format("Expected 1 column to map to %s but encountered %s instead (%s)", 274 resultClass, columnCount, columnLabels.stream().collect(joining(", ")))); 275 } 276 } 277 278 return new StandardTypeResult(value, standardType); 279 } 280 281 /** 282 * Attempts to map the current {@code resultSet} row to an instance of {@code resultClass}, which must be a 283 * Record. 284 * 285 * @param <T> result instance type token 286 * @param statementContext current SQL context 287 * @param resultSet provides raw row data to pull from 288 * @param instanceProvider an instance-creation factory, used to instantiate resultset row objects as needed. 289 * @return the result of the mapping 290 * @throws Exception if an error occurs during mapping 291 */ 292 @Nonnull 293 protected <T extends Record> T mapResultSetToRecord(@Nonnull StatementContext<T> statementContext, 294 @Nonnull ResultSet resultSet, 295 @Nonnull InstanceProvider instanceProvider) throws Exception { 296 requireNonNull(statementContext); 297 requireNonNull(resultSet); 298 requireNonNull(instanceProvider); 299 300 Class<T> resultSetRowType = statementContext.getResultSetRowType().get(); 301 302 RecordComponent[] recordComponents = resultSetRowType.getRecordComponents(); 303 Map<String, Set<String>> columnLabelAliasesByPropertyName = determineColumnLabelAliasesByPropertyName(resultSetRowType); 304 Map<String, Object> columnLabelsToValues = extractColumnLabelsToValues(resultSet); 305 Object[] args = new Object[recordComponents.length]; 306 307 for (int i = 0; i < recordComponents.length; ++i) { 308 RecordComponent recordComponent = recordComponents[i]; 309 310 String propertyName = recordComponent.getName(); 311 312 // If there are any @DatabaseColumn annotations on this field, respect them 313 Set<String> potentialPropertyNames = columnLabelAliasesByPropertyName.get(propertyName); 314 315 // There were no @DatabaseColumn annotations, use the default naming strategy 316 if (potentialPropertyNames == null || potentialPropertyNames.size() == 0) 317 potentialPropertyNames = databaseColumnNamesForPropertyName(propertyName); 318 319 Class<?> recordComponentType = recordComponent.getType(); 320 321 // Set the value for the Record ctor 322 for (String potentialPropertyName : potentialPropertyNames) { 323 if (columnLabelsToValues.containsKey(potentialPropertyName)) { 324 Object value = convertResultSetValueToPropertyType(columnLabelsToValues.get(potentialPropertyName), recordComponentType).orElse(null); 325 326 if (value != null && !recordComponentType.isAssignableFrom(value.getClass())) { 327 String resultSetTypeDescription = value.getClass().toString(); 328 329 throw new DatabaseException( 330 format( 331 "Property '%s' of %s has a write method of type %s, but the ResultSet type %s does not match. " 332 + "Consider creating your own %s and overriding convertResultSetValueToPropertyType() to detect instances of %s and convert them to %s", 333 recordComponent.getName(), resultSetRowType, recordComponentType, resultSetTypeDescription, 334 DefaultResultSetMapper.class.getSimpleName(), resultSetTypeDescription, recordComponentType)); 335 } 336 337 args[i] = value; 338 } 339 } 340 } 341 342 return instanceProvider.provideRecord(statementContext, resultSetRowType, args); 343 } 344 345 /** 346 * Attempts to map the current {@code resultSet} row to an instance of {@code resultClass}, which should be a 347 * JavaBean. 348 * 349 * @param <T> result instance type token 350 * @param statementContext current SQL context 351 * @param resultSet provides raw row data to pull from 352 * @param instanceProvider an instance-creation factory, used to instantiate resultset row objects as needed. 353 * @return the result of the mapping 354 * @throws Exception if an error occurs during mapping 355 */ 356 @Nonnull 357 protected <T> T mapResultSetToBean(@Nonnull StatementContext<T> statementContext, 358 @Nonnull ResultSet resultSet, 359 @Nonnull InstanceProvider instanceProvider) throws Exception { 360 requireNonNull(statementContext); 361 requireNonNull(resultSet); 362 requireNonNull(instanceProvider); 363 364 Class<T> resultSetRowType = statementContext.getResultSetRowType().get(); 365 366 T object = instanceProvider.provide(statementContext, resultSetRowType); 367 BeanInfo beanInfo = Introspector.getBeanInfo(resultSetRowType); 368 Map<String, Object> columnLabelsToValues = extractColumnLabelsToValues(resultSet); 369 Map<String, Set<String>> columnLabelAliasesByPropertyName = determineColumnLabelAliasesByPropertyName(resultSetRowType); 370 371 for (PropertyDescriptor propertyDescriptor : beanInfo.getPropertyDescriptors()) { 372 Method writeMethod = propertyDescriptor.getWriteMethod(); 373 374 if (writeMethod == null) 375 continue; 376 377 Parameter parameter = writeMethod.getParameters()[0]; 378 379 // Pull in property names, taking into account any aliases defined by @DatabaseColumn 380 Set<String> propertyNames = columnLabelAliasesByPropertyName.get(propertyDescriptor.getName()); 381 382 if (propertyNames == null) 383 propertyNames = new HashSet<>(); 384 else 385 propertyNames = new HashSet<>(propertyNames); 386 387 // If no @DatabaseColumn annotation, then use the field name itself 388 if (propertyNames.size() == 0) 389 propertyNames.add(propertyDescriptor.getName()); 390 391 // Normalize property names to database column names. 392 // For example, a property name of "address1" would get normalized to the set of "address1" and "address_1" by 393 // default 394 propertyNames = 395 propertyNames.stream().map(propertyName -> databaseColumnNamesForPropertyName(propertyName)) 396 .flatMap(columnNames -> columnNames.stream()).collect(toSet()); 397 398 for (String propertyName : propertyNames) { 399 if (columnLabelsToValues.containsKey(propertyName)) { 400 Object value = convertResultSetValueToPropertyType(columnLabelsToValues.get(propertyName), parameter.getType()).orElse(null); 401 Class<?> writeMethodParameterType = writeMethod.getParameterTypes()[0]; 402 403 if (value != null && !writeMethodParameterType.isAssignableFrom(value.getClass())) { 404 String resultSetTypeDescription = value.getClass().toString(); 405 406 throw new DatabaseException( 407 format( 408 "Property '%s' of %s has a write method of type %s, but the ResultSet type %s does not match. " 409 + "Consider creating your own %s and overriding convertResultSetValueToPropertyType() to detect instances of %s and convert them to %s", 410 propertyDescriptor.getName(), resultSetRowType, writeMethodParameterType, resultSetTypeDescription, 411 DefaultResultSetMapper.class.getSimpleName(), resultSetTypeDescription, writeMethodParameterType)); 412 } 413 414 writeMethod.invoke(object, value); 415 } 416 } 417 } 418 419 return object; 420 } 421 422 @Nonnull 423 protected Map<String, Set<String>> determineColumnLabelAliasesByPropertyName(@Nonnull Class<?> resultClass) { 424 requireNonNull(resultClass); 425 426 return columnLabelAliasesByPropertyNameCache.computeIfAbsent( 427 resultClass, 428 (key) -> { 429 Map<String, Set<String>> cachedColumnLabelAliasesByPropertyName = new HashMap<>(); 430 431 for (Field field : resultClass.getDeclaredFields()) { 432 DatabaseColumn databaseColumn = field.getAnnotation(DatabaseColumn.class); 433 434 if (databaseColumn != null) 435 cachedColumnLabelAliasesByPropertyName.put( 436 field.getName(), 437 unmodifiableSet(asList(databaseColumn.value()).stream() 438 .map(columnLabel -> normalizeColumnLabel(columnLabel)).collect(toSet()))); 439 } 440 441 return unmodifiableMap(cachedColumnLabelAliasesByPropertyName); 442 }); 443 } 444 445 @Nonnull 446 protected Map<String, Object> extractColumnLabelsToValues(@Nonnull ResultSet resultSet) throws SQLException { 447 requireNonNull(resultSet); 448 449 ResultSetMetaData resultSetMetaData = resultSet.getMetaData(); 450 int columnCount = resultSetMetaData.getColumnCount(); 451 Set<String> columnLabels = new HashSet<>(columnCount); 452 453 for (int i = 0; i < columnCount; i++) 454 columnLabels.add(resultSetMetaData.getColumnLabel(i + 1)); 455 456 Map<String, Object> columnLabelsToValues = new HashMap<>(columnLabels.size()); 457 458 for (String columnLabel : columnLabels) { 459 Object resultSetValue = resultSet.getObject(columnLabel); 460 461 // If DB gives us time-related values, re-pull using specific RS methods so we can apply a timezone 462 if (resultSetValue != null) { 463 if (resultSetValue instanceof java.sql.Timestamp) { 464 resultSetValue = resultSet.getTimestamp(columnLabel, getTimeZoneCalendar()); 465 } else if (resultSetValue instanceof java.sql.Date) { 466 resultSetValue = resultSet.getDate(columnLabel, getTimeZoneCalendar()); 467 } else if (resultSetValue instanceof java.sql.Time) { 468 resultSetValue = resultSet.getTime(columnLabel, getTimeZoneCalendar()); 469 } 470 } 471 472 columnLabelsToValues.put(normalizeColumnLabel(columnLabel), resultSetValue); 473 } 474 475 return columnLabelsToValues; 476 } 477 478 /** 479 * Massages a {@link ResultSet#getObject(String)} value to match the given {@code propertyType}. 480 * <p> 481 * For example, the JDBC driver might give us {@link java.sql.Timestamp} but our corresponding JavaBean field is of 482 * type {@link java.util.Date}, so we need to manually convert that ourselves. 483 * 484 * @param resultSetValue the value returned by {@link ResultSet#getObject(String)} 485 * @param propertyType the JavaBean property type we'd like to map {@code resultSetValue} to 486 * @return a representation of {@code resultSetValue} that is of type {@code propertyType} 487 */ 488 @Nonnull 489 protected Optional<Object> convertResultSetValueToPropertyType(Object resultSetValue, Class<?> propertyType) { 490 requireNonNull(propertyType); 491 492 if (resultSetValue == null) 493 return Optional.empty(); 494 495 if (resultSetValue instanceof BigDecimal) { 496 BigDecimal bigDecimal = (BigDecimal) resultSetValue; 497 498 if (BigDecimal.class.isAssignableFrom(propertyType)) 499 return Optional.ofNullable(bigDecimal); 500 if (BigInteger.class.isAssignableFrom(propertyType)) 501 return Optional.of(bigDecimal.toBigInteger()); 502 } 503 504 if (resultSetValue instanceof BigInteger) { 505 BigInteger bigInteger = (BigInteger) resultSetValue; 506 507 if (BigDecimal.class.isAssignableFrom(propertyType)) 508 return Optional.of(new BigDecimal(bigInteger)); 509 if (BigInteger.class.isAssignableFrom(propertyType)) 510 return Optional.ofNullable(bigInteger); 511 } 512 513 if (resultSetValue instanceof Number) { 514 Number number = (Number) resultSetValue; 515 516 if (Byte.class.isAssignableFrom(propertyType)) 517 return Optional.of(number.byteValue()); 518 if (Short.class.isAssignableFrom(propertyType)) 519 return Optional.of(number.shortValue()); 520 if (Integer.class.isAssignableFrom(propertyType)) 521 return Optional.of(number.intValue()); 522 if (Long.class.isAssignableFrom(propertyType)) 523 return Optional.of(number.longValue()); 524 if (Float.class.isAssignableFrom(propertyType)) 525 return Optional.of(number.floatValue()); 526 if (Double.class.isAssignableFrom(propertyType)) 527 return Optional.of(number.doubleValue()); 528 if (BigDecimal.class.isAssignableFrom(propertyType)) 529 return Optional.of(BigDecimal.valueOf((number.doubleValue()))); 530 if (BigInteger.class.isAssignableFrom(propertyType)) 531 return Optional.of(BigDecimal.valueOf(number.doubleValue()).toBigInteger()); 532 } else if (resultSetValue instanceof java.sql.Timestamp) { 533 java.sql.Timestamp date = (java.sql.Timestamp) resultSetValue; 534 535 if (Date.class.isAssignableFrom(propertyType)) 536 return Optional.ofNullable(date); 537 if (Instant.class.isAssignableFrom(propertyType)) 538 return Optional.of(date.toInstant()); 539 if (LocalDate.class.isAssignableFrom(propertyType)) 540 return Optional.of(date.toInstant().atZone(getTimeZone()).toLocalDate()); 541 if (LocalDateTime.class.isAssignableFrom(propertyType)) 542 return Optional.of(date.toLocalDateTime()); 543 } else if (resultSetValue instanceof java.sql.Date) { 544 java.sql.Date date = (java.sql.Date) resultSetValue; 545 546 if (Date.class.isAssignableFrom(propertyType)) 547 return Optional.ofNullable(date); 548 if (Instant.class.isAssignableFrom(propertyType)) 549 return Optional.of(date.toInstant()); 550 if (LocalDate.class.isAssignableFrom(propertyType)) 551 return Optional.of(date.toLocalDate()); 552 if (LocalDateTime.class.isAssignableFrom(propertyType)) 553 return Optional.of(LocalDateTime.ofInstant(date.toInstant(), getTimeZone())); 554 } else if (resultSetValue instanceof java.sql.Time) { 555 java.sql.Time time = (java.sql.Time) resultSetValue; 556 557 if (LocalTime.class.isAssignableFrom(propertyType)) 558 return Optional.ofNullable(time.toLocalTime()); 559 } else if (propertyType.isAssignableFrom(ZoneId.class)) { 560 return Optional.ofNullable(ZoneId.of(resultSetValue.toString())); 561 } else if (propertyType.isAssignableFrom(TimeZone.class)) { 562 return Optional.ofNullable(TimeZone.getTimeZone(resultSetValue.toString())); 563 } else if (propertyType.isAssignableFrom(Locale.class)) { 564 return Optional.ofNullable(Locale.forLanguageTag(resultSetValue.toString())); 565 } else if (propertyType.isEnum()) { 566 return Optional.ofNullable(extractEnumValue(propertyType, resultSetValue)); 567 } else if ("org.postgresql.util.PGobject".equals(resultSetValue.getClass().getName())) { 568 org.postgresql.util.PGobject pgObject = (org.postgresql.util.PGobject) resultSetValue; 569 return Optional.ofNullable(pgObject.getValue()); 570 } 571 572 return Optional.ofNullable(resultSetValue); 573 } 574 575 /** 576 * Attempts to convert {@code object} to a corresponding value for enum type {@code enumClass}. 577 * <p> 578 * Normally {@code object} is a {@code String}, but other types may be used - the {@code toString()} method of 579 * {@code object} will be invoked to determine the final value for conversion. 580 * 581 * @param enumClass the enum to which we'd like to convert {@code object} 582 * @param object the object to convert to an enum value 583 * @return the enum value of {@code object} for {@code enumClass} 584 * @throws DatabaseException if {@code object} does not correspond to a valid enum value 585 */ 586 @SuppressWarnings({"unchecked", "rawtypes"}) 587 @Nonnull 588 protected Enum<?> extractEnumValue(@Nonnull Class<?> enumClass, 589 @Nonnull Object object) { 590 requireNonNull(enumClass); 591 requireNonNull(object); 592 593 if (!enumClass.isEnum()) 594 throw new IllegalArgumentException(format("%s is not an enum type", enumClass)); 595 596 String objectAsString = object.toString(); 597 598 try { 599 return Enum.valueOf((Class<? extends Enum>) enumClass, objectAsString); 600 } catch (IllegalArgumentException | NullPointerException e) { 601 throw new DatabaseException(format("The value '%s' is not present in enum %s", objectAsString, enumClass), e); 602 } 603 } 604 605 /** 606 * Massages a {@link ResultSet} column label so it's easier to match against a JavaBean property name. 607 * <p> 608 * This implementation lowercases the label using the locale provided by {@link #getNormalizationLocale()}. 609 * 610 * @param columnLabel the {@link ResultSet} column label to massage 611 * @return the massaged label 612 */ 613 @Nonnull 614 protected String normalizeColumnLabel(@Nonnull String columnLabel) { 615 requireNonNull(columnLabel); 616 return columnLabel.toLowerCase(getNormalizationLocale()); 617 } 618 619 /** 620 * Massages a JavaBean property name to match standard database column name (camelCase -> camel_case). 621 * <p> 622 * Uses {@link #getNormalizationLocale()} to perform case-changing. 623 * <p> 624 * There may be multiple database column name mappings, for example property {@code address1} might map to both 625 * {@code address1} and {@code address_1} column names. 626 * 627 * @param propertyName the JavaBean property name to massage 628 * @return the column names that match the JavaBean property name 629 */ 630 @Nonnull 631 protected Set<String> databaseColumnNamesForPropertyName(@Nonnull String propertyName) { 632 requireNonNull(propertyName); 633 634 Set<String> normalizedPropertyNames = new HashSet<>(2); 635 636 // Converts camelCase to camel_case 637 String camelCaseRegex = "([a-z])([A-Z]+)"; 638 String replacement = "$1_$2"; 639 640 String normalizedPropertyName = 641 propertyName.replaceAll(camelCaseRegex, replacement).toLowerCase(getNormalizationLocale()); 642 normalizedPropertyNames.add(normalizedPropertyName); 643 644 // Converts address1 to address_1 645 String letterFollowedByNumberRegex = "(\\D)(\\d)"; 646 String normalizedNumberPropertyName = normalizedPropertyName.replaceAll(letterFollowedByNumberRegex, replacement); 647 normalizedPropertyNames.add(normalizedNumberPropertyName); 648 649 return normalizedPropertyNames; 650 } 651 652 /** 653 * The locale to use when massaging JDBC column names for matching against JavaBean property names. 654 * <p> 655 * Used by {@link #normalizeColumnLabel(String)}. 656 * 657 * @return the locale to use for massaging, hardcoded to {@link Locale#ENGLISH} by default 658 */ 659 @Nonnull 660 protected Locale getNormalizationLocale() { 661 return ENGLISH; 662 } 663 664 /** 665 * What kind of database are we working with? 666 * 667 * @return the kind of database we're working with 668 */ 669 @Nonnull 670 protected DatabaseType getDatabaseType() { 671 return this.databaseType; 672 } 673 674 @Nonnull 675 protected ZoneId getTimeZone() { 676 return this.timeZone; 677 } 678 679 @Nonnull 680 protected Calendar getTimeZoneCalendar() { 681 return this.timeZoneCalendar; 682 } 683 684 /** 685 * The result of attempting to map a {@link ResultSet} to a "standard" type like primitive or {@link UUID}. 686 * 687 * @author <a href="https://www.revetkn.com">Mark Allen</a> 688 * @since 1.0.0 689 */ 690 @ThreadSafe 691 protected static class StandardTypeResult<T> { 692 @Nullable 693 private final T value; 694 @Nonnull 695 private final Boolean standardType; 696 697 /** 698 * Creates a {@code StandardTypeResult} with the given {@code value} and {@code standardType} flag. 699 * 700 * @param value the mapping result, may be {@code null} 701 * @param standardType {@code true} if the mapped type was a standard type, {@code false} otherwise 702 */ 703 public StandardTypeResult(@Nullable T value, 704 @Nonnull Boolean standardType) { 705 requireNonNull(standardType); 706 707 this.value = value; 708 this.standardType = standardType; 709 } 710 711 /** 712 * Gets the result of the mapping. 713 * 714 * @return the mapping result value, may be {@code null} 715 */ 716 @Nonnull 717 public Optional<T> getValue() { 718 return Optional.ofNullable(this.value); 719 } 720 721 /** 722 * Was the mapped type a standard type? 723 * 724 * @return {@code true} if this was a standard type, {@code false} otherwise 725 */ 726 @Nonnull 727 public Boolean isStandardType() { 728 return this.standardType; 729 } 730 } 731}