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