czwartek, 22 października 2009

Oracle + daty + JDBC

Ostatnio musiałem się zmierzyć z problemem związanym z obsługą dat w ORACLE 10. Wprowadzenie do tego problemu można znaleźć tutaj. W dużym skrócie: w przypadku insert do kolumny DATE wartości typu java.sql.Timestamp, czyli z poziomu Hibernate zapisujemy property typu java.sql.Timestamp lub java.util.Calendar/java.util.Date z annotacją @Temporal(TemporalType.TIMESTAMP) ), do bazy trafia zapisana przez nas data z czasem, ale bez milisekund - co jest zrozumiałe, bo DATE nie trzyma milisekund. Jeśli jednak spróbujemy odczytać zapisaną przez nas uprzednio krotkę, podając w warunku where zapisaną przed chwilą wartość - wynik jest pusty!
Pewnym popularnym rozwiązaniem, jest ustawienie specjalnego property dla ORACLE 9/10 JDBC driver: oracle.jdbc.V8Compatible=true. Konsekwencje użycia tego ustawienia można znaleźć tu.
Wyżej wymieniony problem można samemu zdiagnozować za pomocą testu.
Środowisko testowe korzysta z Oracle 10 g (10.2.0), JDBC Driver 10.2.0.4 a same testy są uruchamiane w JUNIT 4 za pomocą Spring TestContext Framework.
Encja, która będzie służyć do testów:

@Entity
@Table(name = "TestTable")
@SequenceGenerator(name = "seq_id", sequenceName = "SQ_TT")
public class TestEntity {
@Id
@GeneratedValue(generator = "seq_id")
Long i;

@Column
String state;

@Column(columnDefinition = "date")
@Temporal(TemporalType.TIMESTAMP)
Date d;

public TestEntity() {
}

public TestEntity( String state, Date d ) {
this.state = state;
this.d = d;
}

Test ukazujący w/w problem

@Test
public void test1(){
TestEntity t = new TestEntity("foo",new Date());
sessionFactory.getCurrentSession().save( t );
sessionFactory.getCurrentSession().flush();
sessionFactory.getCurrentSession().clear();
t = (TestEntity) sessionFactory.getCurrentSession().createQuery( "from TestEntity where d=?").setTimestamp( 0, t.getD()).uniqueResult();
assertThat( t, notNullValue() );
}

Test ten kończy się failure.
Można to łatwo poprawić, gdy "wyzerujemy" milisekundy

TestEntity t = new TestEntity("foo",new Date());
sessionFactory.getCurrentSession().save( t );
sessionFactory.getCurrentSession().flush();
sessionFactory.getCurrentSession().clear();
Calendar cal = Calendar.getInstance();
cal.setTime( t.getD() );
cal.set( Calendar.MILLISECOND, 0 );
t = (TestEntity) sessionFactory.getCurrentSession().createQuery( " from TestEntity where d=?").setTimestamp( 0, cal.getTime()).uniqueResult();
assertThat( t, notNullValue() );

Wykonanie tego testu kończy się sukcesem.
Na tym niestety problem się nie kończy. Pomimo tego, że udało nam się uzyskać poprawny wynik, to potrzeba "zerowania" milisekund jest strasznie upierdliwe, ale co gorsze zapytanie takie nie wykorzystują indeksu, który byłyby założone na kolumnie typu DATE. Można się o tym przekonać wykonując następujące polecenia (użytkownik system) :

select sql_id, child_number,sql_text from V$SQL where sql_text like '%from TestEntity%' order by last_load_time desc;

W wyniku dostaniemy sql_id oraz child_number, obie wartości służą jako input dla polecenia:

SELECT * FROM table(DBMS_XPLAN.display_cursor('<sql_id >',<child_number>));

W przypadku gdy mielibyśmy stworzony indeks np. na 2 kolumnach (jedna z nich będzie typu DATE), to zapytania z warunkiem where, które teoretycznie mogłyby wykorzystywać indeks (, np status=? and d>=? and d mogą się bardzo długo wykonywać.
Jeśli nawet ORACLE użyje indeksu to może zrobić to bardzo nieefektywnie.
Jest to spowodowane tym, że ORACLE będzie "promował" daty w indeksie do typu TIMESTAMP. Taka "promocja" powoduję, że przeglądnięcie indeksu jest nieefektywne. Widać to w planie zapytania:
|*  6 |      INDEX RANGE SCAN         | MOJ_INDEX |   741 |       | |                                                                                                                                                                            
--------------------------------------------------------------------------------------------------------

6 - access("state"=:3)
filter((INTERNAL_FUNCTION("D")>=:1 AND INTERNAL_FUNCTION("D")<:2))

"Promocja" typu jest prezentowana jako INTERNAL_FUNCTION
Rozwiązaniem problemu (brak użycia indeksu oraz potrzeba "zerowania" milisekund) byłoby "wypchnięcie" do bazy z poziomu JDBC wartości typu DATE a nie TIMESTAMP.
Można to zrobić poprzez:
  • używanie funkcji to_date - wymaga to ręcznego wprowadzania daty w postaci String

    @Test
    public void testToDate(){
    TestEntity t = new TestEntity("foo",new Date());
    sessionFactory.getCurrentSession().save( t );
    sessionFactory.getCurrentSession().flush();
    sessionFactory.getCurrentSession().clear();
    String dateInStringFormat = createDateFormat( "yyyy-MM-dd HH:mm:ss" ).format( t.getD() );
    t = (TestEntity) sessionFactory.getCurrentSession().createQuery( " from TestEntity where d=to_date(?,'YYYY-MM-DD HH24:MI:SS')").setString( 0, dateInStringFormat ).uniqueResult();
    assertThat( t, notNullValue() );
    }
  • użycie funkcji cast as
    - cast z TIMESTAMP na DATE nie tylko "rzutuje" jeden typ na drugi, ale także dokonuję zaokrąglenia:

    @Test
    public void testCastMilis500andAbove(){
    Calendar calendar = Calendar.getInstance();
    calendar.setTime( new Date() );
    calendar.set( Calendar.MILLISECOND, 500 );
    TestEntity t = new TestEntity("foo",calendar.getTime());
    sessionFactory.getCurrentSession().save( t );
    sessionFactory.getCurrentSession().flush();
    sessionFactory.getCurrentSession().clear();
    t = (TestEntity) sessionFactory.getCurrentSession().createQuery( " from TestEntity where d= cast (? as date)").setTimestamp( 0, calendar.getTime()).uniqueResult();
    assertThat( t, nullValue() );
    }

    @Test
    public void testCastMillisBelow500(){
    Calendar calendar = Calendar.getInstance();
    calendar.setTime( new Date() );
    calendar.set( Calendar.MILLISECOND, 499 );
    TestEntity t = new TestEntity("foo",calendar.getTime());
    sessionFactory.getCurrentSession().save( t );
    sessionFactory.getCurrentSession().flush();
    sessionFactory.getCurrentSession().clear();
    t = (TestEntity) sessionFactory.getCurrentSession().createQuery( " from TestEntity where d= cast (? as date)").setTimestamp( 0, calendar.getTime()).uniqueResult();
    assertThat( t, notNullValue() );
    }
    Nie objedzie się w takim przypadku od "zerowania" milisekund. W przypadku Criteria API nie wiem w jaki sposób out-of -box używać wywołań funkcji. Chyba trzeba by stworzyć własnego Criterion
  • bezpośrednie użycie typu oracle.sql.DATE:

    @Test
    public void test3ImplicitOracleDateJDBC() throws SQLException{
    TestEntity t = new TestEntity("foo",new Date());
    sessionFactory.getCurrentSession().save( t );
    sessionFactory.getCurrentSession().flush();
    sessionFactory.getCurrentSession().clear();
    SimpleJdbcTemplate jdbcTemplate = new SimpleJdbcTemplate(dataSource);
    Timestamp ts = new Timestamp(t.getD().getTime());
    oracle.sql.DATE d = new oracle.sql.DATE(ts);
    long queryForLong = jdbcTemplate.queryForLong( "select count(* ) from TestTable where d=?", d );
    assertThat( queryForLong, equalTo( 1l ) );
    }

    Skuteczne, ale niewygodne. W przypadku HQL trzeba by pewnie stworzyć custom type, który byłby użyty przy bindowaniu parametrów przy pomocy wywołania

    Query setParameter(int position, Object val, Type type);

    W przypadku użycia Criteria API chyba najwygodniejsze byłoby stworzenie własnego Criterion
  • przy użyciu driveraJDBC w wersji 11.1.0.7.0 zadziała taka konstrukcja:

    @Test
    public void testImplicitType() throws SQLException{
    TestEntity t = new TestEntity("foo",new Date());
    sessionFactory.getCurrentSession().save( t );
    sessionFactory.getCurrentSession().flush();
    sessionFactory.getCurrentSession().clear();
    Connection connection = sessionFactory.getCurrentSession().connection();
    PreparedStatement preparedStatement = connection.prepareStatement( "select count(*) from TestTable where d=?");
    preparedStatement.setObject( 1,new Timestamp(t.getD().getTime()) ,java.sql.Types.DATE );
    assertThat( preparedStatement.executeUpdate(), equalTo( 1 ));
    }
  • Oracle JDBC 9/10 (dla 9 nie sprawdzałem) driver undocumented "feature":

    @Test
    public void testSetTimeMilis500andAbove(){
    Calendar calendar = Calendar.getInstance();
    calendar.setTime( new Date() );
    calendar.set( Calendar.MILLISECOND, 500 );
    TestEntity t = new TestEntity("foo",calendar.getTime());
    sessionFactory.getCurrentSession().save( t );
    sessionFactory.getCurrentSession().flush();
    sessionFactory.getCurrentSession().clear();
    t = (TestEntity) sessionFactory.getCurrentSession().createQuery( " from TestEntity where d= ? ").setTime( 0, calendar.getTime()).uniqueResult();
    assertThat( t, notNullValue() );
    }

    @Test
    public void testSetTimeMilisBelow500(){
    Calendar calendar = Calendar.getInstance();
    calendar.setTime( new Date() );
    calendar.set( Calendar.MILLISECOND, 499 );
    TestEntity t = new TestEntity("foo",calendar.getTime());
    sessionFactory.getCurrentSession().save( t );
    sessionFactory.getCurrentSession().flush();
    sessionFactory.getCurrentSession().clear();
    t = (TestEntity) sessionFactory.getCurrentSession().createQuery( " from TestEntity where d=?").setTime( 0, calendar.getTime()).uniqueResult();
    assertThat( t, notNullValue() );;
    }

    Zgodnie z dokumentacją setTime() powinno zostać skonwertowane na TIME, ale konwersja jest robiona na DATE! Dla Criteria API trzeba by stworzyć własne Criterion, aby móc wymusić na Hibernate użycie setTime() - Hibernate out-of-box decyduje jaką metodę wywołac na PreparedStatement na podstawie typu/annotacji property na której ustawiamy warunek.

Brak komentarzy:

Prześlij komentarz