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