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}