Le avventure in VB.Net di un principiante ex-VB6 - 9
a cura di Oscar Zanin e Diego Cattaruzza (requisiti: Visual Basic Express e SqlServer Express )

Premessa
C'è stato un lungo intervallo tra questa puntata e quella precedente.
Soprattutto a causa dello stile di programmazione un po' disordinato di Oscar e della eccessiva rigidità mentale di Diego (se non gli dici le cose in modo preciso, è più stolido di un computer).

Argomento di questa puntata sarebbero dovute essere le due classi principali che costituiscono l'interfaccia tra il programma e il database.
Invece ne verrà presentata solo una e si parlerà dell'importanza di scrivere codice in un modo che possa essere compreso da altri (e anche da noi stessi, passato un certo lasso di tempo).

Inoltre, si esporrà (senza approfondirne più di tanto alcuni aspetti) il problema dei tipi di dato del database contrapposti ai tipi di dato .Net.

Introduzione
Per interagire con i dati del database, è opportuno predisporre delle classi che fungano da interfaccia tra il programma e il database. In altre parole, bisogna implementare il Data Layer della nostra applicazione, che verrà utilizzato dal Business Layer (le form con le regole e le azioni).

Queste classi espongono un insieme di metodi che, lavorando opportunamente fra loro e interagendo con le form chiamanti, si occupano della scrittura, modifica, cancellazione e spostamento di record di una o più tabelle del database. Queste classi, Tabella e DatiMasterDetails, che verranno illustrate in questo articolo, rappresentano forse il punto nevralgico della porzione di programma che stiamo realizzando. Sono lo snodo obbligatorio dal quale passeranno tutte le form del programma che dovranno interagire con i dati del database SQL.

Nello specifico, la classe Tabella viene sfruttata dove una form è legata a un'unica tabella del database (per esempio la form dell'anagrafica degli Stati), mentre la DatiMasterDetails viene usata quando in una form debbono essere gestite più tabelle relazionate fra loro in un rapporto master-details (ne è un esempio la form dei clienti che coinvolge la tabella clienti che è una master, e le tabelle sedi e riferimenti che sono dei details: la classe DatiMasterDetails si occupa di entrambe le tabelle dettaglio, in riferimento alla tabella master).

[Diego] Questa era l'introduzione di Oscar. E questo è un inciso che volendo potete saltare, ma che vi prego di leggere, poiché parla dell'importanza di usare nomi appropriati a quanto scriviamo nel codice.

I nomi delle due classi originali, DatiMaster e DatiMasterDetails, mi hanno tratto in inganno e hanno generato un primo equivoco sul quale si sono avvitati altri equivoci, che hanno rallentato moltissimo la mia comprensione sia degli appunti che del codice di Oscar

Solo dopo un bel po', mi sono reso conto che gli appunti descrivevano una cosa, ma che i nomi usati nel codice facevano pensare a tutt'altro.

Nella classe DatiMasterDetails, tutte le tabelle venivano istanziate come... DatiMaster, e io non capivo come alcune di esse potessero 'diventare' dettagli.

Si tenga anche ben presente che il tutto sta funzionando benissimo a regime in alcune applicazioni sviluppate da Oscar. Non è che i nomi influiscano sul funzionamento del programma. Influiscono solo sul funzionamento dei due neuroni di Diego, nel senso che li ingrippano tra loro.

L'importanza dei nomi
Nella realtà del codice, la classe DatiMaster è una generica classe Tabella, senza alcunché di particolare che la definisca 'master'. Ma io sono stato in grado di rendermene conto solo dopo un lungo lavoro di analisi. Se poi si considera che mi occupo di questa serie di articoli nei ritagli di tempo...

Dando i nomi giusti alla classe e ai suoi membri, cioè facendo un po' di refactoring sul codice di Oscar, questo è diventato molto più comprensibile ai pochi neuroni di Diego. Perché vi possiate rendere conto dell'importanza dei nomi, espongo la tabella dei membri coi nomi originali, una breve descrizione di ciascuno e i nomi nuovi. E' piuttosto lunga, ma consiglio vivamente di esaminarla, cercando di comprendere (dovrebbe essere intuitivo) il motivo del cambiamento del nome del membro, se c'è stato.

Nome originale Descrizione Nome nuovo
DatiMaster classe Tabella
Campi
adp DataAdapter adp
cbl CommandBuilder cbl
cnn Connection mConnessione
ColonneChiave vettore dei nomi delle colonne chiave mColonneChiave
dst DataSet mDataSet
indiceRecord indice di un record mIndiceRecord
myColonnaModifica nome del campo 'modifica' mColonnaModifica
myOrderBy clausola Order By per la query di selezione della tabella mOrderBy
myQueryComando query di selezione della tabella mQuerySelezione
myStringaConnessione stringa di connessione per l'oggetto Connection mStringaConnessione
ValoriColonneChiave vettore dei valori delle colonne chiave, convertiti string mValoriColonneChiave
Proprietà
Connessione Connection Connessione
ConnStringa stringa di connessione per l'oggetto Connection StringaConnessione
Modifica nome del campo 'modifica' NomeCampoModifica
NumRighe numero record contenuti in tabella NumeroRighe
Ordinamento clausola Order By per la query di selezione della tabella Ordinamento
Query query di selezione della tabella QuerySelezione
Tabella nome della tabella NomeTabella
Metodi
AddChiavi aggiunge una chiave al vettore dei nomi delle colonne chiave AggiungiChiave
AddValoriChiavi aggiunge un valore al vettore dei valori delle colonne chiave AggiungiValoreChiave
AggiornaDB aggiorna il database AggiornaDatabase
CancellaRiga cancella un record CancellaRiga
CaricaDati legge i dati in una DataTabel del DataSet CaricaDati
ControlloModifica controlla in base al campo 'modifica' se il record è modificabile o no Modificabile
ImpostaColonnaModifica imposta a vero o falso il valore del campo 'modifica' del record corrente ImpostaColonnaModifica
ImpostaPosizione legge i valori delle colonne chiave di un record LeggiRecord
ImpostaRigaNuova aggiunge un record alla tabella AggiungiRigaNuova
ModificaRecord imposta a vero il valore del campo 'modifica' del record corrente ImpostaModificaRecord
MoveFirst, MoveLast, MoveNext, MovePrev navigazione MoveFirst, MoveLast, MoveNext, MovePrev
PosizionaRecord trova l'indice del record con i dati valori delle colonne chiave TrovaRecord
ResetValoriChiavi svuota il vettore dei valori delle colonne chiave ResettaValoriChiavi
RitornaRigaCorrente restituisce un puntatore al record corrente RigaCorrente
RitornaRigaNuova restituisce un puntatore a un nuovo record RigaNuova

Se avete avuto la pazienza di arrivare sin qui, forse siete anche giunti alla conclusione che quella di Diego non è sempre pedanteria. Immaginate di rileggere il codice con i vecchi nomi a distanza di qualche mese e chiedetevi se sareste in grado di capire al volo, senza analizzare il codice, cosa è questo o quel membro della classe.
Qui finisce l'inciso sulla forma del codice e ci si può dedicare alla prima delle due classi del Data Layer del nostro programma.

La classe Tabella
Si occupa della gestione di una singola tabella del database SQL.
Aggiungiamo la classe al progetto APP.Data (il nome del file rimane DatiMaster.vb) e digitiamo le direttive:

Imports APP.UI
Imports System.Data.SqlClient

Public Class Tabella

La prima direttiva serve per abbreviare il codice per i metodi della classe Messaggi, la seconda... vi lascio indovinare, mentre introduco i campi (ne manca uno che aggiungeremo in seguito, quando ne parleremo):

  Private adp As SqlDataAdapter
  Private mIndiceRecord As Integer = 0
  Private mColonneChiave As New List(Of String)
  Private mValoriColonneChiave As List(Of String)

Il DataAdapter servirà (vi ricordate la puntata scorsa?) per interfacciare il DataSet e il database relativamente a questa tabella; mIndiceRecord servirà per il corretto posizionamento e lo spostamento fra i record della tabella; gli altri due campi sono due vettori per i nomi e i valori delle colonne chiave della tabella: in sostanza, servono a individuare un preciso record.

Nello sviluppo di una classe, può accadere che si preveda di usarla anche in modi diversi di come si intende fare nella situazione contingente. A esempio, mentre stiamo pensando di usare un particolare costruttore, che per la nostra applicazione va benissimo, implementiamo qualche altro costruttore "caso mai ce ne capitasse la necessità".

E' quello che ha fatto Oscar: ci sono alcuni costruttori che nella nostra applicazione non vengono usati (quello che useremo verrà illustrato in seguito):

  Public Sub New()
    '
  End Sub

  Public Sub New(ByVal stringaConnessione As String)
    mStringaConnessione = stringaConnessione
  End Sub

  Public Sub New(ByVal stringaConnessione As String, ByVal querySelezione As String)
    mStringaConnessione = stringaConnessione
    mQuerySelezione = querySelezione
  End Sub

A essi si passano dei parametri per valorizzare dei campi relativi ad altrettante proprietà. E' opportuno, a questo punto, elencare proprietà e campi relativi (si omette il codice al loro interno):

  Private mConnessione As SqlConnection
  Public Property Connessione() As SqlConnection

  Private mStringaConnessione As String
  Public Property StringaConnessione() As String

  Private mDataSet As DataSet
  Public Property DataSet() As DataSet

  Private mNomeTabella As String
  Public Property NomeTabella() As String

  Private mQuerySelezione As String
  Public Property QuerySelezione() As String

  Private mOrderBy As String
  Public Property Ordinamento() As String

  Private mColonnaModifica As String
  Public Property NomeCampoModifica() As String

  Public ReadOnly Property NumeroRighe() As Integer
    Get
      Return mDataSet.Tables(0).Rows.Count
    End Get
  End Property

Tranne l'ultima, NumeroRighe, che è di sola lettura e che restituisce il numero dei record della tabella nel Dataset di riferimento, e NomeTabella, di cui parleremo nel seguito, le proprietà sono implementate con la solita forma Get-Set, nel senso che non hanno ulteriore codice di comportamento: ricevono o restituiscono un valore.

Quelli che seguono, invece, sono i metodi per gestire le colonne chiave della tabella:

  Public Sub AggiungiChiave(ByVal chiave As String)
    mColonneChiave.Add(chiave)
  End Sub

  Public Sub AggiungiValoreChiave(ByVal valore As String)
    mValoriColonneChiave.Add(valore)
  End Sub

  Public Sub ResettaValoriChiavi()
    mValoriColonneChiave = New List(Of String)
  End Sub

  Private Sub RiempiValoriColonneChiave(ByVal indice As Integer)
    mValoriColonneChiave = New List(Of String)
    For Each nomeColonna As String In mColonneChiave
      mValoriColonneChiave.Add((mDataSet.Tables(0).Rows(indice)(nomeColonna)).ToString)
    Next nomeColonna
  End Sub

Com'è intuibile, i nomi dei campi e i relativi valori trovano corrispondenza grazie alla loro posizione ordinale all'interno dei rispettivi vettori. L'ultimo metodo legge i valori delle colonne chiave traendoli da un record della tabella di cui viene passato l'indice.

Con il codice scritto fino a questo punto, siamo in grado di fornire a un oggetto di classe Tabella tutte le informazioni che servono, come nell'esempio che segue:

    Dim tb As New Tabella
    With tb
      .Connessione = oSqlConnection
      .NomeTabella = "Clienti"
      .QuerySelezione = "SELECT * FROM clienti"
      .Ordinamento = "cliente"
      .AggiungiChiave("idcliente")
    End With

Questo ci obbliga a conoscere la struttura della tabella - e fin qui va bene, perché è perfettamente logico - ma anche a ricordarcela 'sempre', dato che dobbiamo anche digitare espressamente i nomi dei campi chiave, con i possibili rischi di errore.
Sarebbe più pratico che se ne occupi la classe, di ricavare la propria struttura. Ecco quindi un altro costruttore, quello soprannunciato:

  Public Sub New(ByVal connessione As SqlConnection, ByVal tabella As String)
    mConnessione = connessione
    Me.NomeTabella = tabella
  End Sub

A esso passiamo un oggetto Connection e il nome della tabella. Modifichiamo inoltre il codice della proprietà NomeTabella:

  Public Property NomeTabella() As String
    Get
      Return mNomeTabella
    End Get
    Set(ByVal value As String)
      mNomeTabella = value
      GetSchema()
    End Set
  End Property

Nel metodo GetSchema ottengo l'insieme dei campi chiave e popolo il relativo vettore:

  Private Sub GetSchema()
    ' apro la connessione
    mConnessione.Open()
    ' imposto la query
    mQuerySelezione = "SELECT * FROM " & mNomeTabella
    ' istanzio l'adattatore
    adp = New SqlDataAdapter(mQuerySelezione, mConnessione.ConnectionString)
    ' e il dataset, riempiendolo coi dati
    mDataSet = New DataSet
    adp.Fill(mDataSet, "Dati")

    ' punto la tabella
    Dim tb As New DataTable
    tb = mDataSet.Tables(0)

    ' ne ottengo lo schema in un'altra datatable
    Dim sc As New DataTable
    sc = adp.FillSchema(tb, SchemaType.Source)
    ' di questo schema ottengo l'insieme delle chiavi
    Dim pk As DataColumn() = sc.PrimaryKey
    ' se ce ne sono
    If pk.Length > 0 Then
      For Each dc As DataColumn In pk
        ' aggiungo il nome
        AggiungiChiave(dc.ColumnName)
      Next
    End If
    mConnessione.Close()
  End Sub

L'uso del metodo FillSchema del DataAdapter è risolutivo.

La babele dei tipi
A cosa servono i vettori (nomi e valori) delle colonne chiave? A costruire le clausole Where nelle query parametriche che individuano il record su cui si deve agire. Si creano dei comandi con queste query e si popola l'insieme Parameters con oggetti Parameter relativi alle colonne chiave.

A esempio, per controllare il campo 'modifica' di un record, si può costruire un Command aggiungendo parametri con il metodo AddWithValue:

        cmd.Parameters.AddWithValue("@" + nomeColonna.ToString, _
                                    mDataSet.Tables(0).Rows(mIndiceRecord)(nomeColonna.ToString))

Però il metodo AddWithValue non permette di precisare il tipo del campo, il che espone il codice a qualche rischio. Sarebbe meglio usare il metodo Add, che ha un parametro 'tipo di campo'.

[Diego] Attenzione: nei paragrafi che seguono, non ho potuto trovare valide alternative ai termini 'tipo', 'parametro', 'schema' che non generassero confusione. Troverete quindi questi termini ripetuti molto spesso nella stessa frase ma riferiti a entità diverse. Leggete con calma, anche più volte, perché se perdete il filo non potete comprendere.

Qui ci si trova però di fronte al problema dei tipi, anzi: del tipo dei tipi. Il tipo del tipo-di-campo richiesto dal metodo Add non è del tipo System.Type, quello usato da .Net, ma del tipo "Database".Type, cioè quello specifico usato dal Provider di dati (Sqlserver, OleDb, Oracle...).

La DataColumn usata nel ciclo che scandisce l'insieme PrimaryKey, nel metodo GetSchema mostrato sopra, espone un DataType che corrisponde a un System.Type. Il metodo SqlCommand.Parameters.Add usa l'enumerato SqlDbType. I due tipi non sono convertibili tra loro.

Quindi bisogna ricorrere a un altro sistema per leggere lo schema della nostra tabella: si usa un Datareader richiedendo la tabella dello schema della tabella, e dei record di questa 'tabella dello schema' si leggono i campi che ci interessano, quelli del nome e del tipo del campo della tabella. Queste due informazioni vanno a formare un vettore dei tipi delle colonne chiave, che abbiamo volutamente omesso di mettere tra i campi, all'inizio di questo articolo, in previsione di questa spiegazione. Se avessimo cercato di spiegare allora la ragione dell'esistenza del vettore dei tipi avremmo certamente confuso le idee.
Aggiungiamo quindi tra i campi il campo mTipiColonne:

  Private mTipiColonne As New Dictionary(Of String, SqlDbType)

Potete notare anzittuto che si tratta di un dizionario, non di una lista, in modo da poter individuare l'elemento che serve in base alla chiave, che è il nome del campo, per ottenere il valore dell'elemento, che è il tipo del campo; inoltre vengono inventariati tutti i campi della tabella, non solo quelli chiave, dato che servono anch'essi.
Il metodo GetSchema, quindi, è come segue:

  Private Sub GetSchema()
     ' apro la connessione
     mConnessione.Open()
     ' imposto la query
     mQuerySelezione = "SELECT * FROM " & mNomeTabella
     ' imposto un Command
     Dim cmd As New SqlCommand(mQuerySelezione, mConnessione)
     ' ne ottengo un datareader 'speciale'
     Dim rd As SqlDataReader = cmd.ExecuteReader(CommandBehavior.SchemaOnly)
     ' del quale ottengo la tabella dello schema della mNomeTabella
     Dim dt As DataTable = rd.GetSchemaTable
     ' scorro la tabella e popolo il dizionario
     For Each r As DataRow In dt.Rows
      mTipiColonne.Add(r("ColumnName").ToString, CType(r("ProviderType"), SqlDbType))
     Next

Come potete vedere, è necessario indicare 'per nome esteso' le colonne che ci interessano (andrebbe bene anche il numero, ma non vale la pena rendere meno leggibile il codice e andarsi a cercare qual numero mettere). Per conoscere il nome delle colonne dello schema di ciascun campo, ho eseguito un ciclo come questo (attenzione: non fa parte del codice del metodo GetSchema, c'è stato solo per il tempo necessario ad acquisire le informazioni):

    For Each r As DataRow In dt.Rows
      For Each c As DataColumn In dt.Columns
        Debug.Print("{0} ({1}): {2}", c.ColumnName, c.DataType.ToString, r(c).ToString)
      Next
    Next

Se si vuole verificare il contenuto del dizionario:

    For Each kvp As KeyValuePair(Of String, SqlDbType) In mTipiColonne
      Debug.Print("{0}: {1}", kvp.Key, kvp.Value.ToString)
    Next

Avrete inoltre notato la tipizzazione del valore della colonna ProviderType, che è Integer, nel tipo SqlDbType.
Il codice del metodo GetSchema continua con la parte già digitata prima:

    ' istanzio l'adattatore
    adp = New SqlDataAdapter(mQuerySelezione, mConnessione.ConnectionString)
    ' e il dataset, riempiendolo coi dati
    mDataSet = New DataSet
    adp.Fill(mDataSet, "Dati")

    ' punto la tabella
    Dim tb As New DataTable
    tb = mDataSet.Tables(0)

    ' ne ottengo lo schema in un'altra datatable
    Dim sc As New DataTable
    sc = adp.FillSchema(tb, SchemaType.Source)
    ' di questo schema ottengo l'insieme delle chiavi
    Dim pk As DataColumn() = sc.PrimaryKey
    ' se ce ne sono
    If pk.Length > 0 Then
      For Each dc As DataColumn In pk
        ' aggiungo il nome
        AggiungiChiave(dc.ColumnName)
      Next
    End If
    mConnessione.Close()
  End Sub

Adesso è possibile usare il metodo Add per predisporre adeguatamente un Command parametrico, laddove ci servirà.
Ci siamo limitati, al momento, a rilevare solo il tipo del campo, ma è ovvio che con lo stesso sistema potremo rilevare anche altre proprietà, come Size, Scale e Precision, preparando così, più che un dizionario di tipi di colonne, un insieme completo di oggetti Parameter.

I metodi di gestione e navigazione
Risolti i problemi di definizione della tabella, non resta che implementare i vari metodi per la gestione dei dati e della navigazione tra i record della tabella (dobbiamo tenere presente che sulle form ci saranno appositi pulsanti per eseguire le tipiche operazioni).

Il metodo CaricaDati si occupa di caricare i dati prelevandoli dalla tabella SQL in una DataTable "Dati" del DataSet.

  Public Sub CaricaDati()

    Try
      Dim cmd As New SqlCommand
      cmd.Connection = mConnessione
      If mOrderBy.Trim <> "" Then
        cmd.CommandText = mQuerySelezione & " ORDER BY " & mOrderBy
      Else
        cmd.CommandText = mQuerySelezione
      End If

      adp = New SqlDataAdapter(cmd)
      Dim cbl As New SqlCommandBuilder(adp)

      mDataSet = New DataSet
      adp.Fill(mDataSet, "Dati")

      Dim colonneDatiChiave(mColonneChiave.Count - 1) As DataColumn
      For Each nomeColonna In mColonneChiave
        colonneDatiChiave(mColonneChiave.IndexOf(nomeColonna)) = mDataSet.Tables(0).Columns(nomeColonna)
      Next nomeColonna
      mDataSet.Tables(0).PrimaryKey = colonneDatiChiave

      mIndiceRecord = 0
      If mDataSet.Tables(0).Rows.Count = 0 Then
        mIndiceRecord = -1
      End If

    Catch ex As Exception
      'Errori non gestiti
      Messaggi.Errore(mClassName, System.Reflection.MethodBase.GetCurrentMethod(), ex)
    End Try
  End Sub

Nel codice, si istanzia e si predispone un SqlCommand, fornendogli la connessione e la query di selezione, con o senza l'ordinamento; se ne ottiene il DataAdapter da passare a un CommandBuilder.

L'oggetto CommandBuilder non è stato descritto in precedenza e si riporta quanto dice MSDN: "Consente la generazione automatica di comandi di tabella singola per risolvere le modifiche apportate a un oggetto DataSet con il database SQL Server associato". Crea cioè automaticamente i comandi di Insert, Update e Delete.

Il DataAdapter serve anche, come al solito, per creare la DataTable "Dati" nel nostro DataSet; di questa tabella viene anche impostata la proprietà PrimaryKey, tramite un vettore di DataColumn valorizzato scandendo il vettore dei nomi delle colonne chiave; infine viene adeguatamente inizializzato mIndicerecord.

Il metodo Modificabile verifica se un dato record è modificabile, controllando il valore del campo 'modifica':

  Public Function Modificabile() As Boolean
    Static cmd As SqlCommand

    Try

Il Command parametrico è sempre lo stesso, strutturalmente, quindi se ne fa una variabile statica, per non doverlo re-impostare ogni volta.

      If cmd Is Nothing Then
        ' predispongo il command, se non è ancora stato inizializzato
        Dim filtro As String = ""
        'Costruisco il filtro sui campi chiave
        For Each nomeColonna As String In mColonneChiave
          If filtro <> "" Then filtro += " AND "
          filtro += nomeColonna.ToString + " = @" + nomeColonna.ToString
        Next
        cmd = New SqlCommand("SELECT " & mColonnaModifica & " FROM " & mNomeTabella & _
                            " WHERE " + filtro, mConnessione)
        ' preparo l'insieme Parameters, usando il metodo Add
        For Each nomeColonna As String In mColonneChiave
          cmd.Parameters.Add("@" + nomeColonna.ToString, mTipiColonne(nomeColonna.ToString))
        Next nomeColonna
      End If

Se il codice viene eseguito per la prima volta, il Command non è inizializzato e viene predisposto, appunto, una volta per tutte. Prima si costruisce, scandendo la lista dei nomi delle colonne chiave, la stringa per la clausola Where che completerà la query di selezione del comando; query mirata solamente sul campo 'modifica'. Poi, con identica scansione, si prepara l'insieme dei Parameters, usando il metodo Add (è questa la riga per la quale abbiamo escogitato la maniera di sfuggire alla 'babele dei tipi').

      ' valorizzo l'insieme dei parametri
      For Each nomeColonna As String In mColonneChiave
        cmd.Parameters("@" + nomeColonna.ToString).Value = _
                                    mDataSet.Tables(0).Rows(mIndiceRecord)(nomeColonna.ToString)
      Next
      mConnessione.Open()
      Dim risultato As Object = cmd.ExecuteScalar()
      mConnessione.Close()
      If risultato Is DBNull.Value Then
        Return True
      End If

      If Convert.ToBoolean(risultato) Then
        Messaggi.Errore("Record già in modifica !", "Attenzione")
        Return False
      End If
      Return True

A questo punto il Command è preparato e bisogna solo valorizzare, tramite il solito ciclo, i parametri con i valori contenuti nei campi relativi della DataRow con indice mIndiceRecord (record corrente), dell'insieme Rows della DataTable "Dati" del DataSet. Quindi si apre la connessione e si esegue il metodo ExecuteScalar del Command, che restituisce in risultato il valore del primo campo della prima riga restituita dalla selezione, che è il campo 'modifica'. In base a questo valore si restituisce che il record è modificabile oppure no, con eventuale messaggio all'utente. (si omette la parte di codice che gestisce le eccezioni).

Il metodo CancellaRiga si occupa di eliminare il record visualizzato sulla form chiamante dalla DataTable (prima) e dalla tabella del database (poi).
Il metodo richiede un parametro che indica se lo stesso è richiamato per la cancellazione di record da una tabella master o detail. Questo è dovuto al fatto che la classe che stiamo descrivendo (Tabella) è sfuttata più volte (tante quante sono le tabelle master o details contemplate nella form) dalla classe DatiMasterDetails che ne richiama e sfrutta i metodi. Come avrete sicuramente intuito, se questa classe si occupa di una tabella alla volta, la classe DatiMasterDetails si occuperà invece della gestione di più tabelle in rapporto relazionale fra di loro di master-details.

  Public Function CancellaRiga(ByVal tabellaMaster As Boolean) As Boolean

    Try
      If tabellaMaster = True Then
        mDataSet.Tables(0).Rows(mIndiceRecord).Delete()
        AggiornaDataBase()
      Else
        If mDataSet.Tables(0).Rows.Count > 0 Then
          For indice As Integer = 0 To mDataSet.Tables(0).Rows.Count - 1
            mDataSet.Tables(0).Rows(indice).Delete()
          Next
          AggiornaDataBase()
        End If
      End If
      Return True

Se si deve cancellare una riga da una tabella Master, si applica il metodo Delete solo su quella riga. Se invece si devono cancellare righe da una tabella Detail, ammesso che ce ne siano di correlate a quella della tabella Master, il metodo Delete viene applicato a tutte. Sì, tutte: se elimino un Cliente, devo eliminare tutte le sedi e i riferimenti a esso associati.

    Catch ex As DBConcurrencyException

Qui cerchiamo per la prima volta di intercettare un'eccezione precisa. In questo caso si tratta di un'eccezione di concorrenza di accesso ai dati. Nello specifico il programma genererà questa eccezione se il record che si tenta di cancellare è in fase di modifica da parte di un altro utente oppure è stato già cancellato in precedenza da un altro utente.

      CaricaDati()
      If Not TrovaRecord() Then
        Messaggi.Errore("Record già cancellato da un altro utente !", "Attenzione")
        Return False
      End If
      mDataSet.Tables(0).Rows(mIndiceRecord).Delete()
      AggiornaDataBase()
      Return True

    Catch ex As Exception
      'Errori non gestiti
      Messaggi.Errore(mClassName, System.Reflection.MethodBase.GetCurrentMethod(), ex)
      Return False
    End Try
  End Function

Se si incorre in questa eccezione si ricaricano i dati dalla tabella tramite il metodo CaricaDati descritto in precedenza. Quindi si cerca di posizionarsi sul record da cancellare tramite il metodo TrovaRecord (che vedremo in seguito).
Se il record non viene trovato significa che è già stato cancellato da un altro utente: si visualizza un avviso e si restituisce il fallimento dell'operazione. Se il record viene trovato significa che è in fase di modifica presso un altro utente, quindi si ritenta la cancellazione e si restituisce il successo dell'operazione.

Il metodo RigaNuova non fa altro che restituire una nuova DataRow vuota e tipizzata sulla struttura attuale della DataTable.

  Public Function RigaNuova() As DataRow
    Return mDataSet.Tables(0).NewRow
  End Function

Il metodo AggiungiRigaNuova si occupa di inserire nella DataTable una DataRow, che riceve come parametro, salvando inoltre i nuovi valori dei campi chiave nella relativa lista. Si intercetta e si gestisce una eventuale eccezione di valore chiave duplicato.

  Public Function AggiungiRigaNuova(ByRef riga As DataRow) As Boolean

    Try
      'Memorizzo i valori dei campi chiave
      mValoriColonneChiave = New List(Of String)
      For Each nomeColonna As String In mColonneChiave
        mValoriColonneChiave.Add(riga(nomeColonna).ToString)
      Next

      'Aggiungo la nuova riga al DataSet
      mDataSet.Tables(0).Rows.Add(riga)

    Catch ex As ConstraintException
      'Errore di chiave primaria duplicata
      Messaggi.Errore(ex.Message, "Attenzione")
      Return False
    Catch ex As Exception
      'Errori non gestiti
      Messaggi.Errore(mClassName, System.Reflection.MethodBase.GetCurrentMethod(), ex)
      Return False
    End Try
    Return True
  End Function

Il metodo AggiornaDataBase sincronizza il database con la tabella.

  Public Sub AggiornaDataBase()
    Try
      adp.Update(mDataSet, "Dati")
    Catch ex As Exception
      Throw ex
    End Try
  End Sub

In una riga tanta sostanza. Come avete letto nei cenni su ADO.NET, sfruttando il metodo Update del DataAdapter, si applicano alla tabella nel database inserimenti, modifiche e cancellazioni di record corrispondenti a inserimenti, modifiche e cancellazioni di DataRow della nostra tabella.
I parametri passati al metodo Update sono il DataSet e il nome della DataTable (che non è quello della tabella, a meno di coincidenze).
Stavolta, come vedete, l'eventuale eccezione è rilanciata al chiamante, poiché l'aggiornamento è un'operazione il cui fallimento può portare conseguenze gravi.

Il metodo ImpostaModificaRecord imposta a Vero lo stato del campo 'modifica' per la gestione della concorrenza in multiutenza. Nella form viene chiamato quando l'utente preme il pulsante di modifica record.

  Public Function ImpostaModificaRecord() As Boolean

    Try
      RiempiValoriColonneChiave(mIndiceRecord)

      'Forzatura del campo di gestione dello stato di modifica a vero
      mDataSet.Tables(0).Rows(mIndiceRecord)(mColonnaModifica) = True
      AggiornaDataBase()
      Return True

    Catch ex As DBConcurrencyException
      ' si ricaricano i dati e si tenta di trovare il record da modificare
      CaricaDati()
      If Not TrovaRecord() Then
        Messaggi.Errore("Impossibile modificare. Record cancellato da un altro utente. " & _
                        "Cliccare sul pulsante Aggiorna per allineare nuovamente i propri dati " & _
                        "con quelli del database.", "Attenzione")
        Return False
      End If

      'Cerco di forzare nuovamente il campo di gestione dello stato di modifica
      mDataSet.Tables(0).Rows(mIndiceRecord)(mColonnaModifica) = True

      AggiornaDataBase()
      Return True

    Catch ex As Exception
      'Errori non gestiti
      Messaggi.Errore(mClassName, System.Reflection.MethodBase.GetCurrentMethod(), ex)
      Return False
    End Try
  End Function

Dopo aver depositato i dati delle colonne chiave, in vista di una possibile ricerca del record, si imposta il campo 'modifica' a vero e si aggiorna il database. Se si verifica un'eccezione di accesso concorrente, si ricaricano i dati e si ricerca il record da modificare. Se la ricerca non ha successo, il record è stato cancellato da un altro utente: si visualizza un avviso e si restituisce il fallimento dell'operazione. Se la ricerca ha successo, si ritenta la valorizzazone del campo 'modifica' e si aggiorna il database, restituendo il successo dell'operazione.

Il metodo ImpostaColonnaModifica valorizza il campo 'modifica' con il valore del parametro che riceve. Notate che il metodo precedente forzava a Vero lo stesso campo, mentre con questo metodo è possibile impostarlo anche a Falso.
Il metodo precedente, nella form, viene richiamato quanto l'utente inizia la fase di modifica del record visualizzato, mentre questo metodo viene richiamato quando l'utente annulla l'operazione in corso oppure quando preme il "pulsante di emergenza" per lo sblocco del record (vedremo in seguito a cosa serve).

  Public Sub ImpostaColonnaModifica(ByVal flag As Boolean)

    If mIndiceRecord < 0 Then
      Exit Sub
    End If

    Try
      'Cambio il valore del campo di gestione dello stato di modifica
      mDataSet.Tables(0).Rows(mIndiceRecord)(mColonnaModifica) = flag

      'Allineo il DataSet con il database
      AggiornaDataBase()

    Catch ex As Exception
      'Errori non gestiti
      Messaggi.Errore(mClassName, System.Reflection.MethodBase.GetCurrentMethod(), ex)
    End Try
  End Sub

Il metodo RigaCorrente soddisfa due esigenze:

  Public Function RigaCorrente(ByRef riga As DataRow) As Boolean

    If mDataSet.Tables(0).Rows.Count = 0 Or mIndiceRecord < 0 Then
      Return False
    End If
    riga = mDataSet.Tables(0).Rows(mIndiceRecord)
    Return True
  End Function

Il metodo TrovaRecord cerca di trovare il record corrispondente ai valori contenuti nel vettore dei valori delle colonne chiave. Se riesce valorizza il campo mIndiceRecord sulla nuova posizione restituendo il successo dell'operazione, altrimenti restituisce il fallimento dell'operazione, indicando così che il record non è più presente.

  Public Function TrovaRecord() As Boolean

    Dim filtroValoriColonneChiave(mValoriColonneChiave.Count - 1) As Object
    For Each valore As String In mValoriColonneChiave
      filtroValoriColonneChiave(mValoriColonneChiave.IndexOf(valore)) = CType(valore, Object)
    Next valore

    Dim dv As New DataView(mDataSet.Tables(0))
    dv.Sort = mOrderBy

    Dim indiceTrovato As Integer = dv.Find(filtroValoriColonneChiave)
    If indiceTrovato < 0 Then
      Return False
    Else
      mIndiceRecord = indiceTrovato
      Return True
    End If
  End Function

Nel codice, dopo aver costruito il filtro dei valori delle colonne chiave, lo si passa al metodo Find di una DataView costruita sulla tabella ed eventualmente ordinata, e si imposta mIndicerecord di conseguenza. Si tenga presente che se non viene trovata una DataRow corrispondente ai criteri di ricerca, il metodo Find restituisce -1. Inoltre si può notare come il vettore da passare a tale metodo debba essere di Object, richiedendo la tipizzazione a Object dell'elemento String della lista dei valori delle colonne chiave (boxing).

Il metodo LeggiRecord (con un overload) usa il metodo RiempiValoriColonneChiave per popolare la lista relativa con i valori delle colonne chiave della riga corrente o di quella indicata:

  Public Sub LeggiRecord()
    If mDataSet.Tables(0).Rows.Count <= 0 Then
      Exit Sub
    End If

    RiempiValoriColonneChiave(mIndiceRecord)
  End Sub

  Public Sub LeggiRecord(ByVal indice As Integer)
    RiempiValoriColonneChiave(indice)
  End Sub

Ci (e vi) risparmiamo l'illustrazione dei metodi Movexxxx, certi che la loro comprensione non risulterà ardua, leggendone il codice nell'allegato.

Errata Corrige
Nel codice della FrmMain Diego è incappato in una svista, gentilmente segnalata da Mauro Marini (che ringrazio).
Il metodo FormFiglia_ActiveControlChanged va corretto come segue:

  Private Sub FormFiglia_ActiveControlChanged(ByVal sender As Object, _
                                              ByVal e As ActiveControlChangedEventArgs)
    Me.InfoMessage = e.MessaggioInfo
  End Sub

Prima veniva modificato il campo mInfoMessage e non la proprietà, con la conseguenza che non veniva eseguito il codice con cui si visualizza il messaggio nella barra di stato.

Conclusione
In questa puntata è stata illustrata la classe Tabella, prima delle due classi fondamentali per il Data Layer della nostra applicazione PrimiPassi.
Si è anche sottolineata, con qualche speranza di risultare utili, l'importanza di una denominazione congrua dei membri di una classe.
Inoltre è stato usato un interessante metodo per risolvere il problema delle discrepanze tra i tipi di dato .Net e quelli di database.

Il codice a corredo di questo articolo è come al solito disponibile in area download.
Anche per questa puntata, Diego mette a disposizione nel suo blog un post cui scrivere critiche, suggerimenti, richieste di chiarimento.

Arrivederci alla prossima puntata, in cui verrà illustrata la classe DatiMasterDetails.