poniedziałek, 15 czerwca 2009

Oracle i Hibernate

W 2 ostatnich projektach dość intensywnie wykorzystujemy Hibernate jako ORM dla Oracle 10.2.0.1.0. (sterownik JDBC w wersji 10.2.0.4.0.) Takie rozwiązanie nie zawsze okazało się bezproblemowe.
  • "Wrong column type in {nazwa schematu.nazwa kolumny} for column {nazwa kolumny}. Found: date, expected: timestamp". Walidacja schematu przez Hibernate(hibernate.hbm2ddl.auto=validate) wyrzuca wyjątek przy mapowaniu
    @Temporal(TemporalType.TIMESTAMP)
    private java.util.Date published;

    gdy kolumna w bazie jest typu DATE.
    Oracle w wersji 9.2 wprowadził nowy typ danych TIMESTAMP, ale jednocześnie wprowadził mapowanie DATE =>java.sql.Date (
    javax.persistence.TemporalType.Date)
    . Typ java.sql.Date posiada jedynie dane o dacie bez info o czasie, przy czym Oraclowy DATE przetrzymuje dane zarówno o dacie jak i o czasie. Sprawdzenie przez drvier JDBC czy DATE mapuje się na java.sql.TimeStamp(
    javax.persistence.TemporalType.TIMESTAMP)
    jest false i rzucany jest wyjątek. Rozwiązań tego problemu jest kilka, przy czym większość z nich polega na modyfikacji definicji kolumn i/lub zmian w kodzie. Na nasze potrzeby najsensowniejsze okazało się wprowadzenie property (jako property połączenia lub property systemowe) oracle.jdbc.V8Compatible=true. Oznacza to powrót do mapowania między DATE a java.sql.TimeStamp zgodnie z tym jak to było dla Oracle 8i (który nie miał typu TIMESTAMP). W przypadku konfiguracji Spring dla c3p0 definicja DataSource wygląda mniej więcej następująco:

    <bean class="com.mchange.v2.c3p0.ComboPooledDataSource" id="dataSourcePool" method="close">
    <property name="properties">
    <props>
    <prop key="oracle.jdbc.V8Compatible">true</prop>
    </props>
    </property>
    <property name="driverClass" value="${db.driver}"/>
    <property name="jdbcUrl" value="${db.url}"/>
    <property name="user" value="${db.username}"/>
    <property name="password" value="${db.pwd}"/>
    ...
    </bean>

    Ważne jest zachowanie kolejności: najpierw property properties wraz z entry oracle.jdbc.V8Compatible ustawione na true, a dopiero później pozostałe propertiesy.
  • Zgodnie z FAQ oracle.jdbc.V8Compatible zostało wprowadzone aby zapewnić kompatybilność z Oracle 8.1, który nie posiadał typu Timestamp. Oznacza to, że przy przekazywaniu do bazy danych obiektu typu java.sql.Timestamp (z poziomu Hibernate w postaci property typu java.util.Timestamp lub property typu java.util.Calendar/ java.util.Date z annotacją @Temporal(TemporalType.TIMESTAMP) ) zostanie on przekonwertowany na typ bazodanowe DATE , który nie przechowuje milisekund. Czyli, jeśli mamy w bazie kolumne ts typu TIMESTAMP to pomimo mapowania:

    @Column(name="ts)
    java.sql.Timestamp ts;
    lub

    @Column(name="ts)
    @Temporal(TemporalType.TIMESTAMP)
    java.util.Date/java.util.Calendar ts;
    insert/update property ts zapisze do bazy danych date z czasem ale bez milisekund.
    Co ciekawsze przy odczycie, property ts posiada milisekundy !!! (zakres milisekund zależy od tego czy zamapowaliśmy na javowe Calendar, Date czy Timestamp) . Jeśli chcemy z poziomu JDBC przechowywać w Oracle daty wraz z czasem z milisekundami nie można użyć oracle.jdbc.V8Compatible=true. Aby móc jednocześnie walidować schemat wystarczy wskazać typ kolumny jako wartość columnDefinition w annotacji @Column:

    @Column(columnDefinition="date")
    @Temporal(TemporalType.TIMESTAMP)
    private java.util.Date d;
    Dodatkowo warto pamiętac, że w przypadku zapisywania do kolumny DATE wartości typu java.sql.Timestamp (czyli z poziomu Hibernate property typu java.sql.Timestamp lub java.util.Calendar/java.util.Date z annotacją @Temporal(TemporalType.TIMESTAMP) ) do bazy zawsze trafi data z czasem bez milisekund. Jest to logiczne gdyż DATE nie trzyma milisekund. Jednak gdy najpierw zapiszemy do bazy wiersz, którego składową jest kolumna DATE wypełniana z pozimou javy wartością typu java.sql.Timestamp ( z poziomu Hibernate property typu java.sql.Timestamp lub java.util.Calendar/java.util.Date z annotacją @Temporal(TemporalType.TIMESTAMP) ), a następnie będziemy chcieli tą samą wartość wykorzystać w warunku where, może się okazać, że nie znajdziemy już takiej krotki. ORACLE najpierw dokona "promocji" wartości w kolumnie DATE do typu parametru wejściowego. W naszym przypadku wartości w kolumnie typu DATE zostaną "wypromowane" do typu TIMESTAMP, ponieważ nasz wejściowy parametr typu java.sql.Timestamp zostanie zamapowany po stronie bazy na typ TIMESTAMP, przy czym proces "promowanie" dodatkowo uzupełni milisekund zerami. Następnie dopiero zostanie wykonane porównywanie. Nawet jeśli ręcznie "wyzerujemy" milisekundy naszego wejściowego java.sql.Timestamp i w ten sposób znajdziemy żądany wiersz, to trzeba pamiętac, że w zapytaniu ORACLE nie użyje indeksu założonego na kolumnie typu DATE- indeks jest założony na typie DATE a nie TIMESTAMP. W przypadku gdy mielibyśmy włączone oracle.jdbc.V8Compatible takich problemów udałoby się uniknąć, ponieważ sterownik JDBC zamapuje nam typ java.sql.Timestamp na DATE. Więcej na ten temat możemy znaleźć tu i tu
  • "Wrong column type in {nazwa schematu.nazwa kolumny} for column {nazwa kolumny}. Found: char, expected: varchar2(255 char)". Walidacja schematu przez Hibernate(hibernate.hbm2ddl.auto=validate) wyrzuca wyjątek przy mapowaniu:

    @Column(name="name")
    private String name;
    gdy kolumna w bazie jest typu char(x) gdzie x>1.
    Problem wynika z błędu w Hibernate, który mapuje bazodanowy typ char na javowy Character.
    Gdy zmienimy mapowanie na

    @Column(name="name")
    private char name;

    wszystko jest oki, ale będziemy ograniczeni do przechowywania/odczytywania pojedyńczego znaku...
    Aby typ char(x) mapował się na String trzeba albo załadować patch opisany powyżej w zgłoszeniu, albo też wskazać konkretny typ bazdanowy na jaki będzie zamapowane nasze property.
    W tym drugim przypadku wystarczy dopisać columnDefinition

    @Column(name="name", columnDefinition="char")
    private String name;

  • Criterion uwzględniające escapowanie znaków specjalnych do wyszukiwania za pomocą like: http://levanhuy.wordpress.com/2009/02/19/providing-an-escape-sequence-for-criteria-queries/
    Trzeba jednak kod trochę zmodyfikować aby escapował znak służący jako escape character, czyli w tym przypadku trzeba escapowac znak '\'.
  • Wyszukiwanie Clob po zawartości. Posiadając mapowanie
    @Lob
    @Column(name = "CONTENT", nullable = false)
    private char[] content;
    do typu bazodanowego CLOB można wyszukiwać Cloby po zawartości. Nie chcę tutaj pisać na temat wydajności takiego rozwiązania, ale ogólnie jest to możliwe i działa. Wyszukiwanie przy pomocy like wygląda standardowo:
    List list = session.createQuery(" from Obj where content like ?").setParameter(0, "%Ala%".toCharArray()).list();

    W przypadku używanie Criteria najlepiej posłużyc się zmodyfikowaną klasą CriteriaEscape predstawioną powyżej. Dla Clob, które są zamapowane do char[] trzeba dokonać małej poprawki:
    public TypedValue[] getTypedValues(Criteria criteria,
    CriteriaQuery criteriaQuery) throws HibernateException {
    return new TypedValue[]{criteriaQuery.getTypedValue(criteria, propertyName, ("%" + value + "%").toCharArray())};
    }

    Idealnie wydaję się być usunięcie manualnej konkatenacji Stringów i użycie org.hibernate.criterion.MatchMode.toMatchString(). Nie tylko będzie to bardziej eleganckie, ale też bardziej funkcjonalne - można by przekazać implementacje org.hibernate.criterion.MatchMode w konstruktorze klasy i dzięki temu możemy łatwo sterować czy znak '%' ma być na początku/końcu czy i na początku i na końcu szukanej frazy.
    Trochę inaczej to wygląda gdy spróbujemy wyszukiwać Cloby za pomocą operatora równości. Próba wykonania kodu:
    Object object = currentSession.createQuery(" from Obj where content=?" ).setParameter(0, "ipsum".toCharArray()).setMaxResults(1).uniqueResult();

    kończy się wyjątkiem: java.sql.SQLException: ORA-00932: inconsistent datatypes: expected - got CLOB. Nie da się ukryć, że typy faktycznie nie pasują. Można się posiłkować Oraclową funkcją TO_CHAR. W Oracle 10.1.0.4.2 takie zapytanie przejdzie:

    Object object = currentSession.createQuery(" from Obj where to_char(content)=?" ).setParameter(0, "ipsum".toCharArray()).setMaxResults(1).uniqueResult();

    Oracle "po cichu" obetnie CLOB do pierwszych 4000 znaków i wtedy dokona porównania.
    Od wersji 10.2.0.1.0 jednak w takim przypadku dostaniemy błąd:
    SQL Error: ORA-22835: Buffer too small for CLOB to CHAR or BLOB to RAW conversion (actual: {wartość powyżej 4000}, maximum: 4000). Oracle tym razem już nie obetnie "po cichu" CLOB do 4000 znaków, ale można takie zachowanie na wymusić:
    Object object = currentSession.createQuery(" from Obj where to_char(substr(CONTENT,1,4000))=?" ).setParameter(0, "ipsum".toCharArray()).setMaxResults(1).uniqueResult();

  • Przy okazji udało się natrafić na parę "niedoskonałości" Hibernate, które zostały już jakiś czas temu znaleziono, ale nie poprawione

    • Zdefiniowanie Criteria na klasie, która nie jest zmapowana, nie powoduje wystąpienia żadnego wyjątku. Jest to dziwne biorąc pod uwagę, że w HQL coś takiego nie przejdzie. Problem został już dawno zgłoszony
    • Hibernate generuje nie poprawne zapytanie w przypadku projekcji definiującej alias, który jest taki sam jak nazwa property a jednocześnie zdefinujemy restrykcji na tym property. Objawia się to wyjątkiem java.sql.SQLException: ORA-00904: "Y0_": invalid identifier. Dotyczy to jedynie aliasów dla root entity. Problem juz został dawno zgłoszony, przygotowano dla niego patch, ale bezpieczniej jest prefixowanie wszystkich restrykcji na root entity za pomocą aliasu"this"
      ProjectionList projectionList = Projections.projectionList().add( Projections.id().as( "id" ) ).add( Projections
      .property( "name" ).as( "name" ) ).add( Projections.property( "surname" ).as( "surname" ) );
      List result = currentSession.createCriteria(Author.class)
      .setProjection( projectionList )
      .add( Restrictions.eq( "this.surname", "Borchardt") )
      .setResultTransformer( new AliasToBeanResultTransformer(Author.class) )
      .list();
      albo korzystanie z podlasy AliasedProjection napisanej przez Chris Federowicza i Kevin Schmidta

      ProjectionList projectionList = Projections.projectionList().add( new CustomPropertyAliasProjection("id","id"))
      .add( new CustomPropertyAliasProjection("name", "name")).add( new CustomPropertyAliasProjection("surname","surname" ));
      List result = currentSession.createCriteria(Author.class)
      .setProjection( projectionList )
      .add( Restrictions.like( "surname", "Borchardt") )
      .setResultTransformer( new AliasToBeanResultTransformer(Author.class) )
      .list();

      Problem ten nie dotyczy wszystkich baz, np HSQLDB radzi sobie bez problemu gdy warunek w where jest definiowany na aliasie a nie na kolumnie, czyli bład ten tam nie występuję. Ogólnie jest to kolejny przykład "rozjazdu" kiedy system pracuję z bazą A a na testy podpinamy bazę B.


sobota, 6 czerwca 2009

java encoding

Wpis został stworzony na podstawie prezentacji Dawida Weissa "The beauty of debugging".
  • Kodowanie plików źródłowych.
    javac zamienia kod źródłowy na bytecode rozumiany przez JVM. W ten sposób z pliku .java powstaje nam plik .class. W ramach tego procesu literały zawarte w pliku źródłowym trafiają do pliku wynikowego, a dokładnie do constant pool table i zostają zakodowane za pomocą UTF-8 (nie jest to 100% prawdą ale możemy tak przyjąć). javac domyślnie zakłada, że nasz plik źródłowy ma kodowanie zgodne z domyślnym kodowaniem platformy - czyli na moim Windows jest to cp1250. Jeśli mamy w takim razie plik źródłowy a w nim następujący kod:
    String str="żółć";

    to w przypadku gdy kodowanie tego pliku jest cp1250 (czyli zgodne z domyślnym kodowaniem platformy)to podglądając ten plik w edytorze hex, żółć to następujący łańcuch bajtów (hex): BF F3 B3 E6. Kompilacji pliku źródłowego, tworzy plik .class, w którym to łańcuch żółć zostaje zakodowany do postaci UTF-8(hex) : C5BC C3B3 C582 C487. Wszystko super, ale gdy nasz plik źródłowy zostanie zakodowany w UTF8 i skompilujemy go tak jak poprzednio, plik wynikowy będzie tym razem zawierał "bezsensowne krzaki". W pliku źródłowym żółć zostanie zakodowana w UTF8 jako:C5BC C3B3 C582 C487, ale javac domyślnie potraktuje ten plik jako zakodowany w Cp1250 i zostanie przeprowadzona konwersja literałów do UTF8. W ten sposób żółć zakodowana w UTF8 zostaje potraktowana jako ciąg bajtów w cp1250, dla którego będzie robiona konwersja do UTF8. W wyniku tego plik wynikowy zakoduje żółć jako następujący ciąg bajtów(hex):C4B9 C4BD C482 C582 C4B9 E280 9AC3 84E2 80A1. Mechanizm jest następujący: C4B9 w UTF8 jest to kod znaku, który odpowiada znakowi C5 w cp1250, C4BD w UTF8 odpowiada BC z cp120 itd. Trzeba pamiętać aby przy kompilacji wskazać kodowanie plików źródłowych: javac -encoding ... , ponieważ nie zawsze musi być/jest zgodne z kodowaniem domyślnym platformy.
  • Kodowanie znaków dla operacji I/O
    W Java typ char jest reprezentowany jako znak Unicode (Unicode code point). Nie oznacza to jednak, że java działa tylko na systemach z charset Unicode. Java domyślnie dokonuje konwersji do i z Unicode, ale ta domyślna konwersja zadziała poprawnie gdy to co chcemy skonwertować lub to, na co chcemy skonwertować jest kodowane za pomocą domyślnego kodowania znaków na naszym systemie. W Java API jest wiele fragmentów, których działanie jest uzależnione od domyślnego kodowania, np. używanie FileWriter, FileReader, wywołanie getBytes() na obiekcie typu String. Oznacza to, że używanie tych konstrukcji może zupełnie inaczej się zachowywać w zależności od systemu, na którym uruchomimy aplikacje. Następujący kod:
    String str = "żółć";
    FileWriter fileWriter = new FileWriter("plik.txt");
    fileWriter.write(str);
    fileWriter.flush();
    fileWriter.close);

    zapisuje do pliku plik.txt tekst "żółć". Uruchamiam aplikacje pod Windows i za pomocą edytora hex oglądam zawartość pliku plik.txt:AF F3 B3 E6. Domyślne kodowanie na moim komputerze to Cp1250. Można to sprawdzić odpytując system property: file.encoding. W przypadku uruchomienia aplikacji na Linux (tam file.encoding to UTF-8) plik plik.txt zawiera tekst "żółć", ale zakodowany w UTF8 czyli w edytorze hex zobaczymy następujący łańcuch bajtów: C5 BC C3 B3 C5 82 C4 87. Jak widać wynik działania zależy od platformy, na której uruchomimy aplikacje ...Przy uruchomieniu aplikacji można wymusić domyślne kodowanie znaków za pomocą -Dfile.encoding=, lub też (co wydaje się lepsze) unikać korzystania z elementów API, które wykorzystują domyślne kodowanie
W przypadku gdy będziemy chcieli "wypchnąć" znak Unicode, dla którego nie istnieje reprezentacja w kodowaniu, w którym będziemy chcieli ten znak reprezentować, to zobaczymy znak '?'.
char s = '\u01fe';
FileWriter fileWriter = new FileWriter("foo.bar");
fileWriter.write(s);
fileWriter.flush();
fileWriter.close();
System.out.println(s);

Uruchomienie powyższego kodu na moim komputerze z Windows (bez dodawania -Dfile.encoding) skutkuje utworzeniem pliku foo.bar oraz wypisaniem znaku na konsole.
Z racji tego, że znak 'Ǿ' nie jest w żaden sposób reprezentowany w moim domyślnym kodowaniu(cp1250) na konsoli oraz w pliku znajdzie się znak '?' (w hex 3f).
W przypadku gdy przy uruchomieniu aplikacji dodam -Dfile.encoding=UTF8 w pliku zobaczę poprawną reprezentację znaku w kodowaniu UTF8: C7 BE , a na konoli 2 znaczki: Çľ, czyli reprezentaja znaków o kodach odpowiednio C7 oraz BE w kodowaniu cp1250.
Więcej na ten temat tu

W przypadku gdy potrzebujemy sprawdzić dany znak, szczególnie dotyczy to znaków "wklejonych z nieznanych źródeł" najlepiej wyświetlić każdy znak jako code point oraz jego reprezentacje w wybranym kodowaniu, np UTF-8.
    
    int i = 8211;
    printRepresentation(i);  

    char c='ą'
    printRepresentation(c);  

    char c2 = '\u1FB4';
    printRepresentation(c2);  

   private void printRepresentation(int i) throws UnsupportedEncodingException {
        System.out.println((char)i + " = " +i +" " + unicodeCodePoint(i) + " '" + utf8HexRepresentation((char)i)+"'");
    }

   private String unicodeCodePoint(int i) {
        return String.format("U+%04X", i);
    }

    public static String utf8HexRepresentation(char c) throws UnsupportedEncodingException {
        byte[] bytes = (""+c).getBytes("UTF-8");
        return hexString(bytes);
    }
    private static String hexString(byte[] data) {
        StringBuffer s = new StringBuffer();
        for (int i = 0; i < data.length; i++) {
            int high_nibble = (data[i] & 0xf0) >>> 4;
            int low_nibble = (data[i] & 0x0f);
            s.append(hex_table[high_nibble]);
            s.append(hex_table[low_nibble]);
            s.append(" ");
        }
        return s.toString().trim();
    }

    private static char[] hex_table = { '0', '1', '2', '3', '4', '5', '6', '7',
            '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };

Wyszukanie "problematycznego" znaku w pliku/plikach można zrobić za pomocą grepa:
 
grep --colour $'\xe2\x80\x93' *

Jeśli chodzi o kodowanie znaków w J2EE to warto zajrzeć tu

wtorek, 2 czerwca 2009

HamCrest 1.2

Nie dawno pojawił się Hamcrest 1.2. Bardzo mi się podoba idea pisania assercji przy wykorzystaniu
Assert.assertThat(T arg0, Matcher<T> arg1)

Pozwala to nie tylko poprawić czytelność kodu, ale także go znacznie skrócić.
Na stronie domowej projektu jest parę przykładów użycia, ale lista ta jest trochę uboga i najlepiej samemu zacząć zabawę.

Sama konfiguracja pod Eclipse IDE zapowiadała się dość banalnie: wrzucamy hamcrest-all-1.2.jar do classpath i wszystko. Jednak czekała na mnie niemiła niespodzianka i zobaczyłem:java.lang.NoSuchMethodError. Okazuję się, jednak że Junit 4.4 (ten co działa z Spring TestContext Framework) ma sam w sobie okrojonego hamcresta...
Istenieje na szczęście "goły" JUnit4.4: junit-dep-4.4.jar. W takim razie usunąłem z classpath junit-4.4.jar a na jego miejsce podgrałem junit-dep-4.4.jar.
Od tego momentu pod Eclipse wszystko działa jak należy.

Jednak próba uruchomienia, a nawet kompilacji testów z wyrażeniami hamcrest nie działa.
Problem dotyczy Java generics i pojawiał się już wcześniej.
Następujący kod kompiluje się w IDE (jdk1.5.0_12)
Assert.assertThat(4, either(equalTo(4)).or(equalTo(3)));

Jednak odpalenie javac (uruchamiane z pełnej ścieżki do jdk1.5.0_12), wywala następujący błąd:
or(org.hamcrest.Matcher) in org.hamcrest.core.CombinableMatcher<java.lang.Object> cannot be applied to (org.hamcrest.Matcher<java.lang.Integer>)
Assert.assertThat(4, either(equalTo(4)).or(equalTo(3)));

Zgłosiłem issue, ale odpowiedz jest dość standardowa ze strony twórców projektu: "flaky Java generics". W takim razie pozostaję nam jedynie znaleźć workaround i pomóc kompilatorowi...
Assert.assertThat(4, Matchers.<Integer>either(equalTo(4)).or(equalTo(3)));


No i miało być tak pięknie, ale zaczęło się sypać uruchamianie testów z mavena.
Po wywaleniu junit-4.4 i dodaniu zależności do junit4.4-dep oraz hamcrest 1.2:

junit
junit-dep
4.4
true
test


org.hamcrest
hamcrest-core




org.hamcrest
hamcrest-all
1.2
test


SureFire uruchamiał testy nie jako testy JUnit4 a jako zwykłe POJO-tests.
Niestety dość długo zajęło znalezienie przyczyny, i nie obyło się bez. Na szczęście
szybciej poszło znalezienie rozwiązania