Статьи

Утечка абстракций, или Как правильно связать Oracle DATE с Hibernate

Недавно мы опубликовали статью о том, как правильно связать тип Oracle DATE в SQL / JDBC и jOOQ . Эта статья получила небольшую поддержку в Reddit с интересным замечанием Влада Михалча , который часто пишет в своем блоге о Hibernate, JPA, управлении транзакциями и пулах соединений . Влад отметил, что эту проблему также можно решить с помощью Hibernate, и мы вскоре рассмотрим это.

В чем проблема с Oracle DATE?

Проблема, которая была представлена ​​в предыдущей статье, связана с тем, что, если запрос использует фильтры для столбцов Oracle DATE :

1
2
3
4
5
// execute_at is of type DATE and there's an index
PreparedStatement stmt = connection.prepareStatement(
    "SELECT * " +
    "FROM rentals " +
    "WHERE rental_date > ? AND rental_date < ?");

… И мы используем java.sql.Timestamp для наших значений привязки:

1
2
stmt.setTimestamp(1, start);
stmt.setTimestamp(2, end);

… Тогда план выполнения станет очень плохим при ПОЛНОМ СКАНИРОВАНИИ ТАБЛИЦЫ или, возможно, при ПОЛНОМ СКАНИРОВАНИИ ИНДЕКСА, даже если мы должны были получить регулярное СКАНИРОВАНИЕ НА ИНДЕКСНОМ ДИАПАЗОНЕ.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
-------------------------------------
| Id  | Operation          | Name   |
-------------------------------------
|   0 | SELECT STATEMENT   |        |
|*  1 |  FILTER            |        |
|*  2 |   TABLE ACCESS FULL| RENTAL |
-------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   1 - filter(:1<=:2)
   2 - filter((INTERNAL_FUNCTION("RENTAL_DATE")>=:1 AND
              INTERNAL_FUNCTION("RENTAL_DATE")<=:2))

Это связано с тем, что столбец базы данных расширяется от Oracle DATE до Oracle TIMESTAMP помощью этого INTERNAL_FUNCTION() , а не java.sql.Timestamp значение java.sql.Timestamp до Oracle DATE .

Более подробную информацию о самой проблеме можно увидеть в предыдущей статье

Предотвращение этого INTERNAL_FUNCTION () с Hibernate

Вы можете исправить это с помощью проприетарного API Hibernate, используя org.hibernate.usertype.UserType .

Предполагая, что у нас есть следующая сущность:

01
02
03
04
05
06
07
08
09
10
@Entity
public class Rental {
 
    @Id
    @Column(name = "rental_id")
    public Long rentalId;
 
    @Column(name = "rental_date")
    public Timestamp rentalDate;
}

А теперь давайте запустим этот запрос здесь (для примера я использую Hibernate API, а не JPA):

1
2
3
4
5
List<Rental> rentals =
session.createQuery("from Rental r where r.rentalDate between :from and :to")
       .setParameter("from", Timestamp.valueOf("2000-01-01 00:00:00.0"))
       .setParameter("to", Timestamp.valueOf("2000-10-01 00:00:00.0"))
       .list();

План выполнения, который мы сейчас получаем, снова неэффективен:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
-------------------------------------
| Id  | Operation          | Name   |
-------------------------------------
|   0 | SELECT STATEMENT   |        |
|*  1 |  FILTER            |        |
|*  2 |   TABLE ACCESS FULL| RENTAL |
-------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   1 - filter(:1<=:2)
   2 - filter((INTERNAL_FUNCTION("RENTAL0_"."RENTAL_DATE")>=:1 AND
              INTERNAL_FUNCTION("RENTAL0_"."RENTAL_DATE")<=:2))

Решение состоит в том, чтобы добавить эту аннотацию @Type ко всем соответствующим столбцам …

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
@Entity
@TypeDefs(
    value = @TypeDef(
        name = "oracle_date",
        typeClass = OracleDate.class
    )
)
public class Rental {
 
    @Id
    @Column(name = "rental_id")
    public Long rentalId;
 
    @Column(name = "rental_date")
    @Type(type = "oracle_date")
    public Timestamp rentalDate;
}

и зарегистрируйте следующий упрощенный UserType :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.io.Serializable;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.util.Objects;
 
import oracle.sql.DATE;
 
import org.hibernate.engine.spi.SessionImplementor;
import org.hibernate.usertype.UserType;
 
public class OracleDate implements UserType {
 
    @Override
    public int[] sqlTypes() {
        return new int[] { Types.TIMESTAMP };
    }
 
    @Override
    public Class<?> returnedClass() {
        return Timestamp.class;
    }
 
    @Override
    public Object nullSafeGet(
        ResultSet rs,
        String[] names,
        SessionImplementor session,
        Object owner
    )
    throws SQLException {
        return rs.getTimestamp(names[0]);
    }
 
    @Override
    public void nullSafeSet(
        PreparedStatement st,
        Object value,
        int index,
        SessionImplementor session
    )
    throws SQLException {
        // The magic is here: oracle.sql.DATE!
        st.setObject(index, new DATE(value));
    }
 
    // The other method implementations are omitted
}

Это будет работать, потому что использование специфичного для oracle.sql.DATE типа oracle.sql.DATE будет иметь тот же эффект на ваш план выполнения, что и явное приведение переменной bind в вашем операторе SQL, как показано в предыдущей статье : CAST(? AS DATE) . План выполнения теперь желаемый:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
------------------------------------------------------
| Id  | Operation                    | Name          |
------------------------------------------------------
|   0 | SELECT STATEMENT             |               |
|*  1 |  FILTER                      |               |
|   2 |   TABLE ACCESS BY INDEX ROWID| RENTAL        |
|*  3 |    INDEX RANGE SCAN          | IDX_RENTAL_UQ |
------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
 
   1 - filter(:1<=:2)
   3 - access("RENTAL0_"."RENTAL_DATE">=:1
          AND "RENTAL0_"."RENTAL_DATE"<=:2)

Если вы хотите воспроизвести эту проблему, просто запросите в любом столбце Oracle DATE значение привязки java.sql.Timestamp через JPA / Hibernate и получите план выполнения, как указано здесь .

Не забудьте очистить общие пулы и буферные кэши, чтобы обеспечить вычисление новых планов между выполнениями, потому что сгенерированный SQL каждый раз один и тот же.

Могу ли я сделать это с JPA 2.1?

На первый взгляд, похоже, что новая функция конвертера в JPA 2.1 ( которая работает так же, как и функция конвертации в jOOQ ) должна справиться с задачей . Мы должны быть в состоянии написать:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
import java.sql.Timestamp;
 
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
 
import oracle.sql.DATE;
 
@Converter
public class OracleDateConverter
implements AttributeConverter<Timestamp, DATE>{
 
    @Override
    public DATE convertToDatabaseColumn(Timestamp attribute) {
        return attribute == null ? null : new DATE(attribute);
    }
 
    @Override
    public Timestamp convertToEntityAttribute(DATE dbData) {
        return dbData == null ? null : dbData.timestampValue();
    }
}

Этот конвертер может затем использоваться с нашей организацией:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
import java.sql.Timestamp;
 
import javax.persistence.Column;
import javax.persistence.Convert;
import javax.persistence.Entity;
import javax.persistence.Id;
 
@Entity
public class Rental {
 
    @Id
    @Column(name = "rental_id")
    public Long rentalId;
 
    @Column(name = "rental_date")
    @Convert(converter = OracleDateConverter.class)
    public Timestamp rentalDate;
}

Но, к сожалению, это не работает из коробки, поскольку Hibernate 4.3.7 будет думать, что вы собираетесь связать переменную типа VARBINARY :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
// From org.hibernate.type.descriptor.sql.SqlTypeDescriptorRegistry
 
    public <X> ValueBinder<X> getBinder(JavaTypeDescriptor<X> javaTypeDescriptor) {
        if ( Serializable.class.isAssignableFrom( javaTypeDescriptor.getJavaTypeClass() ) ) {
            return VarbinaryTypeDescriptor.INSTANCE.getBinder( javaTypeDescriptor );
        }
 
        return new BasicBinder<X>( javaTypeDescriptor, this ) {
            @Override
            protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options)
                    throws SQLException {
                st.setObject( index, value, jdbcTypeCode );
            }
        };
    }

Конечно, мы, возможно, можем каким-то образом настроить SqlTypeDescriptorRegistry для создания нашего собственного «связующего», но затем мы вернемся к API, специфичному для Hibernate. Эта конкретная реализация, вероятно, является «ошибкой» на стороне Hibernate, которая была зарегистрирована здесь, для записи:

https://hibernate.atlassian.net/browse/HHH-9553

Вывод

Абстракции протекают на всех уровнях, даже если JCP считает их «стандартом». Стандарты часто являются средством оправдания отраслевого стандарта де-факто в ретроспективе (с некоторой политикой, конечно). Давайте не будем забывать, что Hibernate не начинался как стандарт и в корне изменил способ, которым обычные люди из J2EE думали о постоянстве 14 лет назад.

В этом случае мы имеем:

  • Oracle SQL, фактическая реализация
  • Стандарт SQL, который определяет DATE совсем не так, как Oracle
  • ojdbc, расширяющий JDBC для обеспечения доступа к функциям Oracle
  • JDBC, который следует стандарту SQL относительно временных типов
  • Hibernate, который предлагает собственный API для доступа к функциям Oracle SQL и ojdbc при привязке переменных
  • JPA, который снова следует стандарту SQL и JDBC относительно временных типов
  • Модель вашей сущности

Как вы можете видеть, фактическая реализация (Oracle SQL) просочилась прямо в вашу собственную сущностную модель, либо через UserType Hibernate, либо через JPA Converter . С тех пор он, как мы надеемся, будет защищен от вашего приложения (до тех пор, пока это не произойдет), что позволит вам забыть об этой неприятной маленькой детали Oracle SQL.

В любом случае, если вы хотите решить реальные проблемы клиентов (то есть существенную проблему с производительностью), вам придется прибегнуть к API-интерфейсу конкретного поставщика из Oracle SQL, ojdbc и Hibernate — вместо того, чтобы притворяться, что SQL Стандарты JDBC и JPA — это практический результат.

Но это, наверное, хорошо. Для большинства проектов итоговая блокировка реализации полностью приемлема.