Calcite数据源适配器对时间字段的操作问题

之前的文章中,说了如何通过Calcite构建一个Tablesaw的适配器,这篇来说说处理时间类型字段遇到的两个问题:

  • 时间转换问题
  • 时间不正确问题

1、时间转换问题

在定义Tablesaw对Calcite的类型映射的时候,就定义了相应的类型关系:

enum DataFrameFieldType {
    STRING(String.class, ColumnType.STRING),
    TEXT(String.class, ColumnType.TEXT),
    BOOLEAN(Primitive.BOOLEAN, ColumnType.BOOLEAN),
    SHORT(Primitive.SHORT, ColumnType.SHORT),
    INT(Primitive.INT, ColumnType.INTEGER),
    LONG(Primitive.LONG, ColumnType.LONG),
    FLOAT(Primitive.FLOAT, ColumnType.FLOAT),
    DOUBLE(Primitive.DOUBLE, ColumnType.DOUBLE),
    DATE(java.sql.Date.class, ColumnType.LOCAL_DATE),
    TIME(java.sql.Time.class, ColumnType.LOCAL_TIME),
    TIMESTAMP(java.sql.Timestamp.class, ColumnType.LOCAL_DATE_TIME);
}

由上面的枚举可以看出,java.sql.Date对应Table的LOCAL_DATE字段,那是否在Enumerator获取数据的时候,可以直接将LocalDate转为java.sql.Date呢?答案是不行的,你很快收到一个类型错误:

java.lang.ClassCastException: java.time.LocalDate cannot be cast to java.lang.Number
	at org.apache.calcite.avatica.util.AbstractCursor$NumberAccessor.getNumber(AbstractCursor.java:722)
	at org.apache.calcite.avatica.util.AbstractCursor$DateFromNumberAccessor.getDate(AbstractCursor.java:911)
	at org.apache.calcite.avatica.AvaticaResultSet.getDate(AvaticaResultSet.java:281)

从错误信息来看,是DateFromNumberAccessor.getDate报的错误,是强制转换失败。

// DateFromNumberAccessor.java
@Override public Date getDate(Calendar calendar) throws SQLException {
    final Number v = getNumber();
    if (v == null) {
    return null;
    }
    return longToDate(v.longValue() * DateTimeUtils.MILLIS_PER_DAY, calendar);
}

那为啥要使用这个DateFromNumberAccessor?不是定义了java.sql.Date了吗?应该使用DateAccessor啊。想知道为啥使用DateFromNumberAccessor,那只能看看在哪里创建的Accessor。

追溯到AvaticaResultSet的execute()方法,accessorList在这时候创建:

 // AvaticaResultSet.java
  protected AvaticaResultSet execute() throws SQLException {
    final Iterable<Object> iterable1 =
        statement.connection.meta.createIterable(statement.handle, state, signature,
            Collections.<TypedValue>emptyList(), firstFrame);
    this.cursor = MetaImpl.createCursor(signature.cursorFactory, iterable1);
    this.accessorList =
        cursor.createAccessors(columnMetaDataList, localCalendar, this);
    this.row = 0;
    this.beforeFirst = true;
    this.afterLast = false;
    return this;
  }

继续追踪,可以发现AbstractCursor的createAccessor创建Accessor,由columnMetaData.type.id来控制和columnMetaData.type.rep来控制。

// AbstractCursor.java
    ...
    case Types.DATE:
      switch (columnMetaData.type.rep) {
      case PRIMITIVE_INT:
      case INTEGER:
      case NUMBER:
        return new DateFromNumberAccessor(getter, localCalendar);
      case JAVA_SQL_DATE:
        return new DateAccessor(getter);
      default:
        throw new AssertionError("bad " + columnMetaData.type.rep);
      }
    ...

所以,由此可以知道,是由字段的元数据columnMetaData影响着Accessor的创建方式。所以要继续找出创建columnMetaData的方法。从AvaticaResultSet的构造方法可以知道columnMetaData是由Meta.Signature创建的,下一步是要找Meta.Signature的创建方法。

从上面时序图可以知道,CalciteSignature由CalcitePrepareImpl的prepare2_方法中创建,继续追踪avaticaType方法,这里创建了columnMetaData.type,决定了之后如何创建Accessor。看JavaTypeFactoryImpl的getJavaClass方法,这里是决定使用DateFromNumberAccessor的关键:

展开查看
// JavaTypeFactoryImpl.java
public Type getJavaClass(RelDataType type) {
    if (type instanceof JavaType) {
      JavaType javaType = (JavaType) type;
      return javaType.getJavaClass();
    }
    if (type instanceof BasicSqlType || type instanceof IntervalSqlType) {
      switch (type.getSqlTypeName()) {
      case VARCHAR:
      case CHAR:
        return String.class;
      case DATE:
      case TIME:
      case TIME_WITH_LOCAL_TIME_ZONE:
      case INTEGER:
      case INTERVAL_YEAR:
      case INTERVAL_YEAR_MONTH:
      case INTERVAL_MONTH:
        return type.isNullable() ? Integer.class : int.class;
      case TIMESTAMP:
      case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
      case BIGINT:
      case INTERVAL_DAY:
      case INTERVAL_DAY_HOUR:
      case INTERVAL_DAY_MINUTE:
      case INTERVAL_DAY_SECOND:
      case INTERVAL_HOUR:
      case INTERVAL_HOUR_MINUTE:
      case INTERVAL_HOUR_SECOND:
      case INTERVAL_MINUTE:
      case INTERVAL_MINUTE_SECOND:
      case INTERVAL_SECOND:
        return type.isNullable() ? Long.class : long.class;
      case SMALLINT:
        return type.isNullable() ? Short.class : short.class;
      case TINYINT:
        return type.isNullable() ? Byte.class : byte.class;
      case DECIMAL:
        return BigDecimal.class;
      case BOOLEAN:
        return type.isNullable() ? Boolean.class : boolean.class;
      case DOUBLE:
      case FLOAT: // sic
        return type.isNullable() ? Double.class : double.class;
      case REAL:
        return type.isNullable() ? Float.class : float.class;
      case BINARY:
      case VARBINARY:
        return ByteString.class;
      case GEOMETRY:
        return GeoFunctions.Geom.class;
      case SYMBOL:
        return Enum.class;
      case ANY:
        return Object.class;
      case NULL:
        return Void.class;
      }
    }
    switch (type.getSqlTypeName()) {
    case ROW:
      assert type instanceof RelRecordType;
      if (type instanceof JavaRecordType) {
        return ((JavaRecordType) type).clazz;
      } else {
        return createSyntheticType((RelRecordType) type);
      }
    case MAP:
      return Map.class;
    case ARRAY:
    case MULTISET:
      return List.class;
    }
    return null;
  }

这里可以看到如果是JavaType的话,返回我们指定的Java类型,如果是BasicSqlType的话,时间类型会转为int类型。
这里归咎到底是字段类型设置的问题,如果我们之前的类型使用的是SqlType:

// DataFrameFieldType.java
    public RelDataType toType(JavaTypeFactory typeFactory) {
        RelDataType javaType = typeFactory.createJavaType(clazz);
        RelDataType sqlType = typeFactory.createSqlType(javaType.getSqlTypeName());
        return typeFactory.createTypeWithNullability(sqlType, true);
    }

解决方法:

方法1、Enumerator获取的Date要转为EpochDay:

// DataFrameEnumerator.java
    private Object convertToEnumeratorObject(Column<?> column, int row) {
        final TimeZone gmt = TimeZone.getTimeZone("GMT");
        if (column instanceof DateColumn) {
            return ((DateColumn) column).get(row).toEpochDay();
        } else if (column instanceof TimeColumn) {
            return Time.from(
                    ((TimeColumn) column).get(row)
                            .atDate(LocalDate.ofEpochDay(0))
                            .atZone(gmt.toZoneId())
                            .toInstant()
            ).getTime();
        } else if (column instanceof DateTimeColumn) {
            return Timestamp.from(
                    ((DateTimeColumn) column).get(row)
                            .atZone(gmt.toZoneId())
                            .toInstant()
            ).getTime();
        } else {
            return column.get(row);
        }
    }

方法2、如果不想转int的话,直接使用java.sql.Date类型的话,对应Enumerator转为java.sql.Date:

// DataFrameFieldType.java
    public RelDataType toType(JavaTypeFactory typeFactory) {
        RelDataType javaType = typeFactory.createJavaType(clazz);
        return typeFactory.createTypeWithNullability(sqlType, true);
    }
// DataFrameEnumerator.java
    private Object convertToEnumeratorObject(Column<?> column, int row) {
        final TimeZone gmt = TimeZone.getTimeZone("GMT");
        if (column instanceof DateColumn) {
            return new Date(
                    ((DateColumn) column).get(row)
                            .atTime(LocalTime.MIN)
                            .atZone(gmt.toZoneId())
                            .toInstant()
                            .toEpochMilli()
            );
        } else if (column instanceof TimeColumn) {
            return new Time(
                    ((TimeColumn) column).get(row)
                            .atDate(LocalDate.ofEpochDay(0))
                            .atZone(gmt.toZoneId())
                            .toInstant()
                            .toEpochMilli()
            );
        } else if (column instanceof DateTimeColumn) {
            return new Timestamp(
                    ((DateTimeColumn) column).get(row)
                            .atZone(gmt.toZoneId())
                            .toInstant()
                            .toEpochMilli()
            );
        } else {
            return column.get(row);
        }
    }

顺便提一句,如果是直接使用LocalDate也是可以的,但是不能使用对应的时间函数,Jdbc识别不出字段类型。

2、时间不正确问题

最常见的就是相差8个小时的问题。查看DateAccessor的getDate(Calendar calendar)方法:

// DateAccessor.java
    @Override public Date getDate(Calendar calendar) throws SQLException {
      java.sql.Date date = (Date) getObject();
      if (date == null) {
        return null;
      }
      if (calendar != null) {
        long v = date.getTime();
        v -= calendar.getTimeZone().getOffset(v);
        date = new Date(v);
      }
      return date;
    }

v -= calendar.getTimeZone().getOffset(v);,这里结果时间会减去calendar的时区偏移量,从AvaticaResultSet的构造方法看出,这个偏移量由timeZone来构建,在没有指定timeZone参数的情况下,默认使用JVM所在的时区。

// AvaticaConnection.java
  public TimeZone getTimeZone() {
    final String timeZoneName = config().timeZone();
    return timeZoneName == null
        ? TimeZone.getDefault()
        : TimeZone.getTimeZone(timeZoneName);
  }

所以,如果结果时间是GMT+8的时间,那么结果时间就会减去东8时区的偏移量,比实际结果慢8个小时。
解决方法有两个:

  1. 连接属性设置TimeZone为gmt,Enumerator的时间是GMT+8的时间
  2. 连接属性使用Jvm的TimeZone,Enumerator的时间是GMT的时间
posted @ 2020-12-12 20:13  Gin.p  阅读(724)  评论(0编辑  收藏  举报