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个小时。
解决方法有两个:
- 连接属性设置TimeZone为gmt,Enumerator的时间是GMT+8的时间
- 连接属性使用Jvm的TimeZone,Enumerator的时间是GMT的时间