Dispense su JDBC di Marco Baioletti Introduzione In queste note viene fornita una breve introduzione alla tecnologia JDBC che consente ad un'applicazione Java di connettersi ad un database e di eseguire operazioni di interrogazione e manipolazione dati. Questa tipologia di applicazione e' estremamente importante perché consente di utilizzare in modo semplice tutti i servizi offerti dal DBMS direttamente da programma. Tipi di driver JDBC La comunicazione tra l'applicazione Java e il DBMS è svolta da una componente software chiamata driver. Esistono 4 tipi di driver JDBC : 1. JDBC-ODBC. Il driver utilizza un "ponte" creato dalla Sun, che permette l'utilizzo di driver di tipo ODBC, all'interno di applicazioni Java. 2. JDBC basato su funzioni scritte in un linguaggio "nativo" (cioè non in Java) e poi richiamate da Java. 3. JDBC-Net, driver nativo, pure Java, che non comunica direttamente con il DBMS, ma con un middleware, in grado di prendere le chiamate e convertirle in qualcosa che il database è in grado di capire, rispondendo poi di conseguenza. 4. JDBC, pure Java, con accesso diretto. In questo caso non occorre nulla, se non il driver e il DBMS al quale collegarsi. In queste note si farà riferimento al driver di tipo 1 JBDC-ODBC che sarà utilizzato per connettersi, via ODBC, a database MS Access (per questo tipo di database non esistono a tutt'oggi altri driver ufficiali) e un cenno alla connessione con database MySQL con il driver Connector/J di tipo . Operazioni preliminari Creazione della sorgente dati ODBC Questa operazione va fatta la prima volta che si crea l'applicazione. Per poter utilizzare un DB Access tramite JDBC-ODBC è indispensabile definire una sorgente dati ODBC associata al DB in questione. Le seguenti istruzioni sono valide per Windows XP. Per altri sistemi operativi i passi da compiere sono simili. 1. Andare su Menù Start 2. Selezionare Pannello di controllo 3. Selezionare Strumenti di amministrazione 4. Selezionare Origine dati (ODBC) 5. su Origine dati utente selezionare "Database di Microsoft Access" e cliccare su Aggiungi 6. Selezionare come driver per l'origine dati "Driver di Microsoft Access" e cliccare su Fine 7. Digitare il nome da dare all'origine dati (questo nome sarà la terza componente dell'URL del database) 8. Sul riquadro Database cliccare Seleziona (per scegliere un DB già esistente) o Crea (per creare un nuovo DB) 9. In ogni caso bisogna fornire il pathname del DB 10. Alla fine cliccare su OK. Caricamento del driver La prima operazione che deve essere svolta in un'applicazione che usa JDBC e' il caricamento del driver. Il driver va caricato con un'istruzione particolare Class.forName specificando tra parentesi il "percorso" in cui si trova il driver. Per utilizzare il driver che verrà usato negli esempi l'istruzione e' Class.forName("sun.jdbc.odbc.JdbcOdbcDriver"); Questa istruzione va eseguita prima di ogni altra operazione sul database. E' obbligatorio gestire con il costrutto try/catch l'eventuale eccezione generata. Creazione dell'oggetto connessione L'oggetto connessione e' un oggetto mediante il quale si specifica a quale database far eseguire le operazioni richieste dal programma. Nel caso in cui un programma utilizzi più database, ci devono essere tanti oggetti connessione quanti sono i database utilizzati contemporaneamente. Un oggetto connessione si dichiara con Connection c; e si crea con c=DriverManager.getConnection(URL_DB); in cui URL_DB e' una stringa divisa in tre parti separate dal carattere di due punti. Le prime due parti sono obbligatoriamente jdbc e odbc. La terza parte è il nome dell'origine dati ODBC (fornita nel punto 7 delle istruzioni precedenti). Ad esempio per connettersi ad un database avente come origine dati ODBC il nome prova_db1 l'URL del DB è jdbc:odbc:prova_db1. E' obbligatorio gestire con il costrutto try/catch l'eventuale eccezione generata. L'oggetto connessione va creato prima di operare con un database. Poiché ogni operazione fatta sul database deve utilizzarlo, esso deve essere memorizzato in una variabile visibile all'intero programma. Se il programma è composto da un'unica classe la variabile c può essere definita all'interno della classe. Se invece il programma ha più classi, la variabile c può essere messa in una classe statica. Connessione con un database MySQL Per utilizzare il driver che verrà usato per connettersi ad un database MySQL si usa la classe Class.forName("com.mysql.jdbc.Driver"); Per connettersi ad un database di nome prova_db2 all'indirizzo www.azienda.it tramite l'utente rossi e password segreto l'URL del DB è jdbc:mysql://www.azienda.it:3306/prova_db2?user=rossi&password=segreto in cui 3306 è la porta TCP/IP su cui per default lavora MySQL. Per connessioni in locale usare l'indirizzo localhost. Operazioni sul database L'oggetto Statement L'oggetto Statement serve a eseguire istruzioni SQL sul database. Esso può essere utilizzato sia per eseguire comandi veri e propri (inserimento/modifica/cancellazione record, ecc.), sia interrogazioni sul DB. Creazione L'oggetto Statement si dichiara con Statement st; e si crea con st=c.createStatement( ); in cui c e' l'oggetto connessione da usare. Utilizzo per eseguire comandi Per utilizzare l'oggetto Statement bisogna specificare una stringa contenente il comando SQL da eseguire. La cosa più semplice, anche per trovare eventuali errori di programmazione, è quella di utilizzare una variabile stringa, ad esempio dichiarata con String SQL; su cui si memorizza il comando SQL. A questo punto, se il comando SQL da eseguire non è un interrogazione (quindi può essere un'operazione di INSERT, UPDATE, DELETE o addirittura un comando del DDL), si usa il metodo st.executeUpdate(SQL); e il comando verrà eseguito. Il metodo e' una funzione e restituisce come risultato il numero di record coinvolti nel comando. Se però non serve, si può ignorare questo risultato. Ad esempio int nr; String SQL; SQL="insert into prodotti values(3,'penne',10)"; nr=st.executeUpdate(SQL); Nella variabile nr restituirà 1, perché solo un record è stato toccato dal comando. Se non mi serve sapere quanti record sono stati coinvolti basterà usare SQL="insert into prodotti values(3,'penne',10)"; st.executeUpdate(SQL); Si noti che uno stesso oggetto Statement può essere utilizzato senza problemi per inviare più comandi al DB SQL="insert into prodotti values(3,'penne',10)"; st.executeUpdate(SQL); SQL="insert into prodotti values(4,'matite',10)"; st.executeUpdate(SQL); Utilizzo per eseguire interrogazioni Per far eseguire un'interrogazione si deve usare il metodo executeQuery che restituisce come risultato un oggetto di tipo ResultSet con il quale è possibile consultare e, in certi casi, modificare il risultato dell'interrogazione. ResultSet rs; ... rs=st.executeQuery(SQL); E' possibile stabilire il massimo numero di righe che possono essere gestite in un resultset utilizzando il comando st.setMaxRows(nr); Inoltre è possibile indicare un tempo massimo di esecuzione con il comando st.setQueryTimeout(sec); Comandi parametrici E' molto frequente che l'istruzione SQL da eseguire, sia essa un comando o un'interrogazione, contengano delle parti, chiamate parametri, note solo a tempo di esecuzione. Il classico esempio è quello di estrarre tutte le informazioni di una persona, a partire dal cognome, che l'utente fornisce durante l'esecuzione del programma, ad esempio tramite una TextField. String SQL; String cognome=jTextField1.getText( ); SQL="select * from persona where cognome='" + cognome + "'"; Sono indispensabili i due apici singoli. Per capire a cosa servono si supponga che il cognome digitato sia Rossi. La stringa memorizzata nella variabile SQL sarà select * from persona where cognome='Rossi'. Gli apici infatti servono in SQL a delimitare le costanti stringa. Se per errore si omettono gli apici, si avrà un'eccezion SQL in quanto il comando inviato al DBMS sarebbe select * from persona where cognome=Rossi, che è sbagliato poiché si tenterebbe di confrontare il campo cognome con il campo rossi (che ovviamente non esiste). In un altro esempio si vogliono trovare le informazioni di un prodotto a partire dal codice, che è di tipo numerico. In tal caso gli apici non si devono mettere, in quanto in SQL le costanti numeriche non devono essere delimitate da apici o virgolette. Ad esempio String codice=jTextField2.getText( ); SQL="select * from prodotto where codice=" + codice; Si noti che il codice è memorizzato in una variabile di tipo stringa, ma è inserito nella query senza apici e come tale verrà trattato in SQL come se fosse un intero. Ad esempio se l'utente ha digitato il codice 10, la stringa SQL conterrà il comando select * from prodotto where codice=10. Ovviamente se l'utente ha inserito nel TextField un dato non numerico, si avrà comunque un'eccezione SQL. Ad esempio se l'utente ha inserito la parola due, la stringa inviata al DBMS sarà select * from prodotto where codice=due, che produce lo stesso errore dell'esempio precedente. Oggetto di tipo ResultSet L'oggetto ResultSet puo' essere utilizzato in due modi. Nel primo modo (l'unico possibile con il JDBC 1.0) il ResultSet serve ad accedere in sola lettura al risultato di un'interrogazione. Un'interrogazione quasi sempre restituisce un insieme di righe e il resultset consente di vedere una riga per volta e di muoversi all'interno di queste righe (solo di una riga in avanti nel JDBC 1.0, in altre direzione o modalità con il JDBC 2.0). Nel secondo modo il ResultSet può servire a modificare il contenuto di una tabella del database, potendo cancellare, inserire e modificare le righe Opzioni di creazione Quando si crea un oggetto Statement, si possono specificare due parametri tipo_scroll e tipo_concorrenza che specificano in modo più accurato il tipo di ResulSet che si vuole ottenere. Quindi si usa Statement st=c.createStatement(tipo_scroll, tipo_concorrenza) in cui tipo_scroll puo' essere ResultSet.TYPE_FORWARD_ONLY: il resultset può essere scorso solo in avanti; ResultSet.TYPE_SCROLL_INSENSITIVE: il resultset può essere scorso sia in avanti che all'indietro, ma non viene aggiornato se nel frattempo la sorgente dei dati viene modificata; ResultSet.TYPE_SCROLL_SENSITIVE: il resultset può essere scorso sia in avanti che all'indietro, inoltre il resultset è sempre tenuto aggiornato rispetto alla sorgente dei dati. Invece tipo_concorrenza puo' essere ResultSet.CONCUR_READ_ONLY: il resultset serve solo per leggere i dati della tabella o della query ResultSet.CONCUR_UPDATABLE: è possibile modificare i dati del resultset aggiornando la tabella di origine Per default, cioè se non si indica niente, tipo_scroll è ResultSet.TYPE_FORWARD_ONLY e tipo_concorrenza è ResultSet.CONCUR_READ_ONLY. Movimento Il ResultSet consente l'accesso ad una sola riga per volta, chiamata la riga corrente del ResultSet. Appena creato il ResultSet, la riga corrente non è la prima, ma una riga fittizia precedente la prima riga. Analogamente esiste anche una riga fittizia successiva all'ultima riga. Il ResultSet fornisce, a partire dal JDBC 2.0, una serie di metodi per "navigare" all'interno delle righe che permettono di passare ad una nuova riga corrente: rs.next( ), va alla riga successiva rs.previous( ), va alla riga precedente rs.first( ), va alla prima riga rs.last( ), va all'ultima riga rs.absolute(n), va alla riga numero n (a partire dall'inizio se n>0, a partire dalla fine se n<0) rs.relative(n), si sposta di n righe rispetto alla posizione attuale (in avanti se n>0, all'indietro se n<0) rs.afterLast( ), va alla riga fittizia dopo l'ultima riga rs.beforeFirst( ), va alla riga fittizia prima della prima riga Se il tipo_scroll e' FORWARD_ONLY l'unico movimento possibile è next. Per usare gli altri movimenti, il tipo_scroll deve essere uno degli altri due tipi. Tutti i metodi, tranne gli ultimi due, restituiscono true se la posizione di arrivo è una posizione valida, false altrimenti. Ad esempio il metodo next dà false quando è eseguito sull'ultima riga, true se eseguito su una qualsiasi altra riga; il metodo previous dà false quando è eseguito sulla prima riga, true se eseguito su una qualsiasi altra riga. Controllo della posizione Il ResultSet fornisce una serie di metodi funzione a valore booleano per controllare la posizione della riga corrente all'interno del ResultSet: rs.isFirst( ), restituisce true se la riga corrente è la prima riga rs.isBeforeFirst( ), restituisce true se la riga corrente è la riga fittizia prima della prima riga rs.isLast( ), restituisce true se la riga corrente è l'ultima riga rs.isAfterLast( ), restituisce true se la riga corrente è la riga fittizia dopo l'ultima riga Lettura campi E' possibile ottenere il valore di un campo della riga corrente con i due metodi rs.getXXX(nome_campo) rs.getXXX(indice_campo) XXX puo' essere String, Int, Float, Double, Date, Boolean, ecc. Il nome del campo deve essere quello indicato nella parte Select del comando SQL o quello utilizzato nel DB (se nella Select si è usato *). L'indice del campo deve essere un numero intero, a partire da 1, che rappresenta la posizione del campo nella lista dei campi della Select o nella lista dei campi della tabella del DB. In questo e negli esempi successivi si utilizzerà una tabella prodotti avente i seguenti campi codice, di tipo numerico intero descrizione, di tipo stringa prezzo, di tipo numerico reale a doppia precisione. In questo primo esempio si vogliono trovare la descrizione e il prezzo dei prodotti che costano più di 10 euro. SQL="select codice, descrizione from prodotti where prezzo>10"; rs=st.executeQuery(SQL); while(rs.next()) { String d; double p; d=rs.getString("descrizione"); p=rs.getDouble("prezzo"); System.out.println(d+" "+p); } In questo secondo esempio si vuole riempire un ComboBox con i prodotti presenti nella tabella prodotti, in particolare si vuole che nella ComboBox appaiano solo le descrizioni dei prodotti. Innanzitutto bisogna definire una classe prodotto class prodotto { public int codice; public String descrizione; public double prezzo; public String toString( ) { return descrizione; } } in cui si definisce nel metodo toString cosa si vuole visualizzare. Poi si riempe il ComboBox SQL="select * from prodotti"; rs=st.executeQuery(SQL); while(rs.next()) { prodotto p=new prodotto; p.codice=rs.getInt("codice"); p.descrizione=rs.getString("descrizione"); p.prezzo=rs.getDouble("prezzo"); jComboBox1.addItem(p); } Aggiornamento campi Si può modificare un campo della riga corrente con i due metodi rs.updateXXX(nome_campo, nuovo_valore) rs.updateXXX(indice_campo, nuovo_valore) XXX puo' essere String, Int, Float, Double, Date, Boolean, ecc. Negli esempi seguenti si suppone che rs sia un resultset aggiornabile. Ad esempio, se vogliamo cambiare la descrizione del prodotto della riga corrente rs.updateString("descrizione","patate"); Una volta che la riga è stata cambiato, il DB può essere aggiornato con il comando rs.updateRow( ) Cancellazione record Con il metodo rs.DeleteRow( ) si cancella, sia dal ResultSet che dalla tabella sorgente nel DB, la riga corrente. Inserimento record L'inserimento di un nuovo record avviene con un procedimento un po' complesso. L'ordine da seguire è il seguente 1. rs.moveToInsertRow( ): con questo primo metodo si crea una nuova riga vuota in una posizione "indefinita" all'interno del ResultSet 2. rs.updateXXX(...): con questi metodi si inseriscono i valori nei campi della nuova riga 3. rs.insertRow( ): la nuova riga viene accettata sia nel ResultSet che nel DB 4. rs.moveToCurrentRow( ): ci si posiziona sulla quella che era la riga corrente prima dell'operazione di inserimento. Ad esempio rs. moveToInsertRow( ); rs.updateInt("codice",8); rs.updateString("descrizione","gomme"); rs.updateDouble("prezzo",1.2); rs.insertRow( ); rs.moveToCurrentRow( ); Gestione delle transazioni Normalmente il JDBC considera ogni istruzione SQL inviata al DBMS come se fosse una transazione a sé, cioè chiede al DBMS di fare il commit ad ogni comando. Questa modalità di lavoro si chiama auto-commit. Per creare una transazione è necessario disabilitare questa opzione. Il comando per ottenere ciò è c.setAutoCommit(false); A questo punto tutti i comandi SQL successivi faranno parte della transazione. La transazione, come è noto, può avere due esiti: successo o fallimento. Nel primo caso il DBMS deve fare il commit, cioè deve registrare in modo definitivo le modifiche sul database. Nel secondo caso il DBMS deve annullare le eventuali modifiche apportate. L'esito con successo si indica con l'istruzione c.commit( ) mentre l'esito con fallimento si indica con c.rollback( ) I due comandi sono indispensabili: senza di essi la transazione non andrebbe né al commit, né al rollback. I due esiti devono essere raggiunti in due diramazioni diverse del programma, ad esempio attraverso una if si stabilisce se la transazione fallisce oppure no. La if è però in grado di gestire solo transazioni per cui l'esito è determinato da una condizione (ad esempio su richiesta dell'utente). Un motivo più frequente in grado di influenzare l'esito di una transazione è il verificarsi di un errore in fase di esecuzione (istruzioni SQL errate, vincoli del DB non rispettati, errori di sistema, problemi hardware, ecc.). In tali casi è indispensabile usare il costrutto try-catch. Lo schema è il seguente: c.setAutoCommit(false); try { istruzione istruzione ... istruzione c.commit( ); } catch(SQLException e) { // gestione dell'errore ... c.rollback( ); } Se non accadono errori, la transazione termina con successo e va al commit. Se invece si verifica un errore, l'esecuzione passa al blocco catch, la transazione viene annullata tramite il comando di rollback. Ci possono essere più blocchi catch relativi a tipi di eccezioni diversi; inoltre non tutti necessariamente causano un esito negativo della transazione (in tal caso devono terminare con c.commit( )). Si noti che senza il comando rollback del blocco catch, la transazione non viene annullata dal DBMS. Ecco un esempio di transazione che non va mai al commit c.setAutoCommit(false); try { Statement c=c.createStatement( ); st.executeUpdate("insert into prodotti values(8,'pane',1.10)"); st.executeUpdate("insert into prodotti values(8,'carne',21)"); c.commit( ); } catch(SQLException e) { c.rollback( ); } Il motivo è che codice è una chiave della tabella prodotti e quindi non è possibile inserire due record con la stessa chiave. La transazione viene eseguita fino alla seconda executeUpdate, viene sollevata un'eccezione e la transazione è annullata: anche l'effetto della prima executeUpdate viene annullato dal comando rollback (la transazione deve essere sempre atomica). Isolamento delle transazioni E' possibile indicare il livello di isolamento delle transazione da utilizzare. Le anomalie prese in considerazione sono S = lettura sporca: una transazione T1 legge un dato prodotto da una transazione T2 ancora in corso e che poi fallirà R = lettura non ripetibile: una transazione T1 legge una riga una prima volta, poi rileggendola una seconda la trova diversa perché nel frattempo è stata modificata da una transazione T2 che è arrivata al commit F = righe fantasma: una transazione T1 legge o opera, attraverso una query, delle righe di una tabella una prima volta, una transazione aggiunge o toglie alcune righe da quella tabella, poi T1 rilegge le righe con la stessa query e ne trova un numero diverso. La differenza tra le anomalie R e F è molto sottile: nella seconda lettura con R si possono vedere cambiamenti dei valori di una o più righe, invece con F si possono vedere altre righe o non vedere più alcune. Si noti che bloccando l'anomalia R non si bloccano, generalmente, le anomalie F, soprattutto se si aggiungono nuove righe. Il livello è definito con il comando c.setTransactionIsolation(livello) in cui livello è un numero intero che puo' assumere i seguenti valori (definiti come costanti) Connection.TRANSACTION_READ_UNCOMMITTED: una transazione può leggere dati prodotti da transazioni ancora in corso, anomalie ammesse S, R, F Connection.TRANSACTION_READ_COMMITTED: una transazione può leggere solo dati prodotti da transazioni terminate con successo, anomalie ammesse R, F Connection.TRANSACTION_REPEATABLE_READ: anomalia ammessa F Connection.TRANSACTION_SERIALIZABLE: nessuna anomalia ammessa