|
Ancora un nuovo modello per l'accesso ai dati.
Come ormai Microsoft ci ha abituato da tempo anche nella versione 2005 dell'ambiente di sviluppo, la gestione dell'accesso ai dati subisce alcune modifiche, questa volta molto meno radicali rispetto alle versioni precedenti ma non per questo meno importanti. In particolare in quest' ultima versione vengono migliorati i già potenti strumenti messi a disposizione con la versione 2003 orientandosi verso uno sviluppo più veloce dell'accesso ai dati.
Con l'introduzione di ADO.NET Microsoft introduce [finalmente! n.d.a.] il concetto di gestione dei dati attraverso il modello disconnesso. La necessità di consumare i dati cercando di limitare i tempi di connessione ai loro repository (database, server di database, driver odbc, ecc...) scaturisce dalla consapevolezza che l'utilizzo "selvaggio" dei cursori da parte degli utenti sviluppatori ha generato più problemi che benefici, riducendo le performances degli applicativi e creando abnormi problemi di concorrenza sui dati. Non da ultimo, l'elevata richiesta di gestione dei dati da parte degli applicativi Web che iniziavano ad interessarsi all'aspetto gestionale e non più informativo, ha definitivamente orientato lo sviluppo dell'accesso ai dati verso la necessità di lavorare disconnessi, ottenendo anche il beneficio di unificare al modello ADO.NET sia gli applicativi Windows Forms sia gli applicativi basati su ASP.
Si inizia a lavorare con nuovi concetti già dalla prima versione di VS .NET e quindi vengono introdotti nuovi oggetti per la gestione dei dati:
- DataSet: un "mini-database" definito attraverso XML che rappresenta un contenitore di oggetti caricato in memoria.
- DataTable: le tabelle presenti all'interno del DataSet.
- DataColumns: le colonne delle DataTable ad elevata tipizzazione.
- DataRows: le righe delle DataTable anch'esse ad elevata tipizzazione.
- DataRelation: rappresenta le relazioni esistenti tra DataTable presenti all'interno dello stesso DataSet.
- DataConstraints: ovvero i vincoli che possono essere attivati su una DataColumn (PrimaryKeyConstraint, ForeignKeyConstraint, UniqueConstraint, ecc..).
- DataView: rappresenta una vista personalizzata di dati associati ad una DataTable; viene utilizzata per filtrare e/o ordinare i dati presenti in un DataTable.
- DataConnection: oggetti che gestiscono la connessione alla base dati.
- DataCommand: oggetti capaci di inviare comandi (tipicamente stringhe SQL o eseguire Stored Procedure) per aggiornare, consumare o eliminare righe nel Database.
- DataParameter: rappresentano i parametri da passare ad un oggetto DataCommand al fine di eseguirlo correttamente.
- DataAdapter: oggetti capaci di incorporare al loro interno più DataCommand al fine di centralizzare il percorso di Creazione-->Lettura-->Modifica-->Aggiornamento-->Eliminazione dei dati attraverso l'esecuzione di Query altamente tipizzate.
- TableAdapter: Permettono la comunicazione tra l'applicazione ed il DataBase. Possono essere paragonati a dei DataAdapter che includono anche un oggetto DataConnection e la capacità di contenere più query di selezione dei dati.
Chiaramente questo è solo un estratto di tutte le classi presenti nel Namespace System.Data perchè oltre a queste vengono esposte classi per la gestione ed il controllo delle altre classi del Namespace.
Come si usano gli oggetti di Accesso ai dati.
Creare un DataSet
La creazione di un DataSet può essere fatta attraverso il wizard di Visual Studio collegandosi direttamente all'origine dati oppure inserendo una nuova classe derivata dalla classe DataSet direttamente nella struttura del progetto. La creazione attraverso il wizard permette contemporaneamente di recuperare anche i recordset che si vogliono definire, direttamente dal DataBase. Il wizard crea anche i rispettivi TableAdapter tipizzati per ogni nuova tabella creata. Al termine della creazione guidata il DataSet verrà esposto in un nuova Classe a livello di Namespace di progetto, ovvero la combinazione NomeProgetto.NomeClasseDataSet rappresenta lo Strong Name del DataSet stesso.
Gestire la stringa di connessione al DataBase
Se viene utilizzato il wizard per la creazione del DataSet, in uno dei passaggi di creazione ci viene chiesto di definire ed eventualmente salvare la stringa di connessione nel settings dell'applicativo, per poter essere utilizzatga ogni qual volta il TableAdapter necessita di eseguire un command. Se per necessità diverse si deve impostare una stringa di connessione diversa da quella creata dal wizard, ad esempio in un ambiente con più client ed il database centralizzato, oppure in installazioni dove questo valore può essere soggetto a modifiche, generalmente si opera o impostando la correttamente la proprietà del file di settings, oppure implementando un'interfaccia che permetta all'utente di modificarla in fase di startup dell'applicazione. Tipicamente viene utilizzata una maschera di Login Utente che implementa anche questa funzionalità. L'utilizzo di SQL Server (anche in versione Express) permette di utilizzare la sicurezza integrata a livello di sistema (o di dominio) per accreditare l'utente all'accesso al DataBase, fornendo già un efficiente supporto per gestire la sicurezza. L'uso della sicurezza integrata a livello di sistema può essere doppiamente utile poichè solo gli utenti autorizzati ad accedere al DataBase possono essere autenticati anche a livello di applicazione. Questa logica viene realizzata soltanto controllando che la connessione al DataBase venga aperta correttamente dopo aver autorizzato lato DataBase soltanto alcuni utenti presenti già a livello di sistema. Oltremodo, ogni TableAdapter fornisce una proprietà ActiveConnection con la quale è possibile impostare l'oggetto connection da utilizzare per l'esecuzione dei relativi command del TableAdapter.
Utilizzare le DataTable
Una DataTable rappresenta una tabella di dati in memoria cache lato client. Tecnicamente è uno schema XML ad alta tipizzazione contenente una collection di oggetti DataColumn e popolato attraverso una collection di oggetti DataRow. A loro volta gli oggetti DataRow definiti per ogni oggetto DataTable espongono proprietà ad alta tipizzazione che rappresentano il valore effettivo del campo del singolo record. A titolo di esempio si veda la porzione di DataSet sotto rappresentato:

Come possiamo notare la DataTable creata dal wizard di importazione di Visual Studio è composta da due aree principali: la DataTable Customers con le relative DataColumn rilevate sulla tabella originaria, ed il TableAdapter CustomersTableAdapter che espone due metodi di default: Fill e GetData. Entrambi i metodi richiamano la stessa query SQL ma eseguono due distinte operazioni: il metodo Fill accetta come parametro un oggetto CustomersDataTable che, al termine dell'esecuzione del metodo, conterrà i record recuperati dal DataBase ed il metodo GetData che restituisce un oggetto di tipo CustomersDataTable che conterrà anch'esso i i record recuperati dal DataBase.
Breve descrizione delle DataRow
Una DataRow è una riga in memoria, appartenente ad una DataTable, composta da campi definiti dalla collection DataColumns della DataTable stessa. La DataRow, di fatto, rappresenta il corrispondente lato client del record sul DataBase. Al fine di poter gestire al meglio i flussi di aggiornamento della Base Dati, le DataRow presentano una proprietà chiamata RowState che indica lo stato corrente della riga stessa. Il tipo della proprietà RowState è RowStateEnum e può assumere i seguenti valori:
-
Unchanged: La riga non presenta modifiche dall'ultima chiamata al metodo AcceptChanges o al metodo Fill.
-
Added: La riga è stata aggiunta alla collection DataRows della DataTable e per questa riga verrà utilizzato l'InsertCommand al momento in cui viene richiamato il metodo Update del TableAdapter.
-
Modified: La riga è stata modificata dopo l'ultimo caricamento dal database e per questa riga verrà utilizzato l'UpdateCommand al momento dell'aggiornamento.
-
Deleted: La riga è stata contrassegnata per l'eliminazione e al successivo aggiornamento verrà utilizzato il DeleteCommand. Una riga contrassegnata per l'eliminazione non viene fisicamente rimossa dalla Collection Rows finchè il metodo di aggiornamento non viene richiamato, altrimenti il DeleteCommand non è in grado di avere le corrette informazioni per l'aggiornamento del DataBase. Spesso si crede che richiamare il metodo DataTable.Rows.Remove abbia lo stesso effetto che richiamare il metodo Delete della DataRow, in realtà il metodo Remove rimuove la riga dalla Collection Rows e proprio per questo motivo al successivo richiamo del metodo Update il DeleteCommand non viene richiamato per la riga rimossa che continua ad essere presente nel DataBase.
-
Detached: La riga è stata creata ma non appartiene a nessuna Collection Rows, ovvero non appartiene alla DataTable.
Approfondiamo il TableAdapter
Uno sguardo particolare va dato al TableAdapter. Questa nuova classe rappresenta un concentrato di funzionalità con le quali possiamo gestire quasi tutto il processo di gestione e manipolazione dei dati. Se apriamo la pagina delle proprietà di un TableAdapter essa si presenta nella forma sotto rappresentata:

In azzurro è stato evidenziato l'oggetto Connection che viene utilizzato come connessione di default per tutti gli oggetti DataCommand (evidenziati in giallo) definiti nel TableAdapter. L'oggetto connection utilizza la stringa di connessione salvata nella proprietà denominata NorthwindConnectionString nel Settings del progetto (evidenziata in verde). I 4 oggetti command evidenziati in giallo sono stati creati direttamente dal wizard al termine della sua esecuzione, ogni oggetto command viene utilizzato nel momento in cui vengono richiamati i metodi di select e di update del DataAdapter. I quattro command si dividono in due gruppi: command di aggiornamento: DeleteCommand, InsertCommand e UpdateCommand e command di Selezione. I tre command di aggiornamento gestiscono i tre diversi stati in cui si può trovare una DataRow del DataTable dopo aver subito un'azione di modifica, di inserimento o di eliminazione. Quando viene richiamato il metodo Update del TableAdapter, viene a sua volta richiamato il metodo Update del DataAdapter incorporato nel TableAdapter. Il metodo Update del DataAdapter a sua volta inizia a ciclare su tutte le righe della DataTable di riferimento e, a seconda del valore RowState, esegue l'aggiornamento nel DataBase utilizzando il rispettivo Command. L'aggiornamento viene tentato per ogni singola riga, qualora il comando generasse un conflitto di aggiornamento il metodo si interrompe e viene sollevata un'eccezione gestibile in un blocco Try/Catch. Se si verifica un errore in fase di aggiornamento tipicamente il TableAdapter continua l'esecuzione del metodo di Update con tutte le righe della DataTable, al termine se si vogliono recuperare le righe in errore si può agire in due modi: ciclando sulle righe della DataTable e controllando la proprietà HasError di ogni singola DataRow, oppure richiamare il metodo GetErrors che restituisce una collection delle righe che hanno generato un errore in fase di aggiornamento; una volta recuperate le righe in errore queste dovranno essere evidenziate all'utente per capire come risolvere gli errori generati.
Risolviamo le eccezioni più comuni: trucchi sull'aggiornamento dei dati.
Violazione della Primary Key
Tra gli errori più comuni che possono essere generati in fase di aggiornamento dei dati attraverso un TableAdapter, uno dei più diffusi sicuramente riguarda la violazione della chiave primaria quando si utilizzano campi autoincrementanti (Identity). Nel caso vengano utilizzate tabelle con campi di questo tipo, il wizard di creazione del TableAdapter inserisce nella CommandString dell'InsertCommand anche il codice necessario al recupero della riga appena inserita al fine di aggiornare immediatamente lato client, ovvero nel DataSet, i riferimenti corrispondenti a quelli presenti nel DataBase. Cambiamo tabella sul DataBase di esempio ed analizziamo il comando SQL di Insert generato dal wizard:
INSERT INTO [dbo].[Products]
([ProductName], [SupplierID], [CategoryID], [QuantityPerUnit], [UnitPrice], [UnitsInStock],
[UnitsOnOrder], [ReorderLevel], [Discontinued])
VALUES (@ProductName, @SupplierID, @CategoryID, @QuantityPerUnit,
@UnitPrice, @UnitsInStock, @UnitsOnOrder, @ReorderLevel, @Discontinued);
SELECT ProductID, ProductName, SupplierID, CategoryID, QuantityPerUnit, UnitPrice, UnitsInStock,
UnitsOnOrder, ReorderLevel, Discontinued
FROM Products WHERE (ProductID = SCOPE_IDENTITY())
Il command è suddiviso in due comandi SQL successivi separati da un punto e virgola, il primo esegue l'insert della nuova riga ed il secondo (quello evidenziato) recupera i dati appena inseriti per sincronizzare il dato effettivamente elaborato lato server con il dato presente lato client. Questa operazione potrebbe sembrare anche superflua poichè tecnicamente si recupera gli stessi dati inviati un istante prima. In realtà questa è una delle operazioni più importanti ai fini del mantenimento della congruenza dei dati perchè sulla tabella del Database potrebbero essere impostate alcune regole di aggiornamento dei dati tipo formule, trigger o quanto altro che modificano il dato realmente inviato, oppure, e questo è il caso più comune ci potrebbe essere un campo identity che modifica il valore della chiave primaria della riga. Questo è un caso in cui il processo di Update potrebbe generare un errore abbastanza difficile da capire. Nel caso in cui la chiave primaria restituita dal DataBase sia già presente nella DataTable lato client, verrà generato un errore di violazione di PrimaryKey sulla DataTable. Vediamo di chiarire con un esempio e di proporre una soluzione "smart" per risolvere questo problema. Al momento dell'esecuzione del metodo di Update presumiamo di avere questa situazione sia nel DataBase che nel DataTable:

Se inserisco due nuove righe nella DataTable il campo ProductID autoincrementante assegnerà il successivo valore alla DataTable lato client.

Supponiamo che nel frattempo un'altro utente da un'altra istanza del programma è andato a inserire una nuova riga nel DataBase il quale ha assegnato al nuovo record il ProductID n. 78. Se in questa situazione richiamo il metodo Update del TableAdapter il mio client eseguirà l'Insert sulla tabella del DataBase il quale assegnerà al record il ProductID n. 79, sempre il command di Update cercherà di recuperare i dati ma troverà già sul DataTable una riga con ProductID n. 79 che genererà un errore di violazione di chiave primaria lato DataTable. Come possiamo risolvere questo errore e se possibile evitare di generarlo?
Per la soluzione del problema possiamo scegliere due strade, la prima presuppone che il database sia disponibile ogni qualvolta eseguo un inserimento, la seconda invece lavora costantemente off-line e permette maggiore flessibilità, sinceramente è quella che preferisco. La prima soluzione è quella di richiamare il metodo update ogni volta che una nuova riga viene inserita ovvero non avere mai nella DataTable due righe con RowState = Added contemporaneamente.
La seconda soluzione è un piccolo ma efficace trucco che garantisce che il valore del campo incrementale sulla DataTable sia sempre e comunque diverso dall'incrementale che il DataBase genera. Tra le proprietà della DataColumn ne troviamo due che permettono di gestire come gli incrementali devono essere generati, la prima proprietà è denominata AutoIncrementSeed e la seconda AutoIncrementStep, rispettivamente indicano il valore iniziale di una colonna di tipo AutoIncrement ed il valore di incremento di ogni nuovo ID generato rispetto al precedente. Il valore di default impostato dal wizard è rispettivamente di 0 per il Seed e di 1 per lo Step. Il DataBase SQL invece normalmente imposta a 1 il Seed e a 1 lo Step. Quando inseriamo la prima riga essa assume valore di ID = 0 per cui, se aggiungiamo più righe, già dalla prima volta che richiamiamo il metodo update viene generato l'errore sopra descritto. Il trucco consiste nell'impostare il valore di Step a -1 ovvero di decrementare sempre di una unità il valore di Autoincrement così che sulla DataTable avrò sempre e solo valori negativi in append e dal database mi ritorneranno sempre e solo valori positivi di Autoincrement, eliminando di fatto il problema alla radice. Questo tipo di logica non può essere usata nel caso in cui non vengano utilizzati i campi Identity nel DataBase come chiavi primarie, ma in questo caso si presuppone che la logica di generazione delle chiavi primarie tenga conto delle problematiche relative alla concorrenza. Chiaramente se lato DataBase vengono utilizzate chiavi primarie ad incremento negativo, basta ribaltare la logica lato DataTable.
Gestione dei DBNull
Un altro errore abbastanza comune che si presenta utilizzando il wizard di creazione dei DataSet è relativo alla gestione dei campi con valore Null. Può verificarsi il caso che, in fase di aggiornamento del DataBase, alcuni campi di testo presentino un valore Null che il DataBase non è in grado di accettare, sollevando un'eccezione al momento dell'aggiornamento. Onde evitare che si verifichi questo inconveniente è possibile impostare un valore predefinito per tutti i campi di tipo String della DataTable direttamente dal designer. Per fare ciò basta aprire la pagina delle proprietà della DataColumn e verificare i sotto indicati valori:

Come si può notare la prima proprietà indica se la colonna è in grado di supportare valori DBNull (AllowDBNull), questa proprietà è stata impostata dal wizard leggendo direttamente nel DataBase il rispettivo valore. Se il campo nel DataBase (come in questo caso) non accetta valori Null si viene a creare un'anomalia poichè per default la proprietà DefaultValue della DataColumn sul DataSet viene impostata a e se viene rilevato un valore DBNull nel campo la DataTable solleverà un'eccezione (NullValue = (Thrown exception))
Per evitare di sollevare questa eccezione (le eccezioni è sempre bene prevenirle che gestirle poichè un'eccezione comunque consuma risorse) è sufficiente impostare a stringa vuota la proprietà DefaultValue e a (Nessuna) il valore della proprietà NullValue che significa che nessuna azione verrà intrapresa nel caso in cui si rilevi un valore DBNull.

In realtà un valore DBNull non potrà mai essere impostato su quel campo perchè:
-
In fase di creazione della DataRow quel campo assume valore di Stringa vuota che è accettato correttamente sia lato client che lato DataBase.
-
Se si cerca di modificare il valore del campo a DBNull la proprietà AllowDBNull genererà un'eccezione in quanto impostata a False, obbligandoci così a specificare un valore corretto per il campo ProductName preso in esame.
Questo esempio ci porta un'ulteriore conferma della potenza e della flessibilità dell'utilizzo dei nuovi oggetti ADO.NET: il controllo di congruenza dei dati in gran parte può essere già fatto lato client impostando i comportamenti degli oggetti di gestione molto simili a quelli presenti sul DataBase. Questa nuova caratteristica che non era presente nelle precedenti versioni delle tecnologie di accesso ai dati, apre nuove frontiere e nuovi modi di programmare gli applicativi basati sui dati.
Considerazioni finali e conclusioni
Il nuovo modello di gestione dei dati ADO.NET proposto da Microsoft già dalla prima versione del Framework 1.0, indubbiamente presenta notevoli differenze con quelli sino ad oggi messi a disposizione ai programmatori. Senza dubbio l'innovazione più consistente riguarda l'architettura incentrata sulla sola opportunità di lavorare disconnessi dalla base dati, diversamente da ciò che accadeva in precedenza. Quello che ho voluto evidenziare in questo articolo è la facilità di utilizzo dei nuovi strumenti di accesso ai dati e la loro flessibilità, pur mantenendo già lato client il rigore di cui un DataBase ha bisogno.
Questo articolo sicuramente non si fermerà qua perchè in esso già possiamo intravedere le numerose potenzialità che possiamo utilizzare con ADO.NET. Il lavorare disconnessi non solo ci permette di rendere più leggera e portabile la nostra applicazione, ma anche di implementare logiche di aggiornamento ed interazione tra DataBase diversi lavorando su connessioni diverse per ogni singolo Command, oppure, e qua abbiamo una delle caratteristiche a mio avviso più consistenti di ADO.NET, di svincolare un'applicazione da qualsiasi DataBase e lavorare utilizzando la serializzazione in locale del DataSet, senza perdere la maggior parte dei controlli e delle tecnologie che un DataBase relazionale ci mette a disposizione.
Ma di questo ne parleremo in futuro.
Per inviare commenti, feedback, domande, oppure per segnalare un errore, potete utilizzare il form commenti in calce all'articolo oppure potete contattare direttamente l'autore.
|