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

Premessa
Nell'articolo precedente non è stata illustrata l'implementazione del codice per richiamare la form di ricerca, perché a una prima analisi Diego aveva riscontrato diversi elementi nel codice originale di Oscar che non gli piacevano, a cominciare proprio dal modo di chiamare la form. Così, ha approfondito l'analisi e ha scoperto altri difetti.

In questo articolo Diego critica il lavoro di Oscar - che, si ribadisce, funziona perfettamente - cercando di evidenziarne l'errore di fondo, poiché ritiene possibile che questo errore sia piuttosto comune.

Pertanto, tutto il codice mostrato in questo articolo, benché funzionante, non troverà posto nella nostra applicazione PrimiPassi, ma ne verrà scritto altro, che sarà argomento della prossima puntata.

La logica di funzionamento
Nel codice originale di Oscar, la FrmRicerca è una finestra di dialogo che riceve un parametro di impostazione e espone un metodo che 'apre' la form stessa e restituisce la lista dei campi chiave con i quali identificare il record cercato e mostrarlo sulla form chiamante.

  Private Sub btnCerca_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) _
                             Handles btnCerca.Click
    Dim frm As New frmRicerca
    Dim campiChiave As List(Of String)

    'Indico alla form di ricerca quale è la form chiamante
    frm.TipoRicerca = "stati"
    'Aprire finestra di ricerca
    campiChiave = frm.ApriRicerca()

Segue l'utilizzo dei metodi ResettaValoriChiavi, AggiungiValoreChiave e TrovaRecord dell'oggetto Tabella sotteso dalla form chiamante, di cui infine si esegue il metodo VisualizzaDati.
Il metodo ApriRicerca, nella sua forma più semplice, contiene il codice seguente:

    Public Function ApriRicerca() As List(Of String)
        Me.ShowDialog()
        Return CampiChiave
    End Function

Ciò che non va bene, in questa impostazione, non è l'uso di un metodo di FrmRicerca per farsi restituire un valore, come qualcuno potrebbe pensare, ma il fatto che non viene considerato che FrmRicerca è una form di dialogo, che restituisce quindi un DialogResult, del cui valore bisogna tenere conto. Infatti, l'utente potrebbe anche 'trovare' un record, ma non volere che poi esso venga visualizzato nella form chiamante. Con il codice di Oscar, viene sempre restituito un risultato (magari vuoto, ed è questo l'unico caso considerato) da passare ai metodi di Tabella sopraindicati.

Inoltre, viene impostata la proprietà TipoRicerca subito dopo l'istanza di FrmRicerca, dato che questo valore è discriminante per l'impostazione della form. Dato che questo valore è necessario e indispensabile, è meglio che esso venga passato come parametro del costruttore della FrmRicerca.

Secondo me, bisogna che la FrmRicerca esponga una proprietà (di sola lettura per impedire eventuali interventi esterni) con il risultato, che verrà letto dalla form chiamante se il DialogResult è Ok. A esempio:

  Public ReadOnly Property CampiChiave() As List(Of String)
    Get
      Return mCampiChiave
    End Get
  End Property

I componenti della FrmRicerca
Oscar intende centralizzare nella FrmRicerca la ricerca di un record per tutte le form del progetto, quindi ha cercato di renderla il più versatile possibile. A seguito del valore impostato dalla form chiamante nella proprietà TipoRicerca, la FrmRicerca si autoconfigura, partendo dalla presenza iniziale dei seguenti componenti:

In teoria, il numero massimo dei criteri impostabili (sei) potrebbe essere un limite, ma non è ragionevolmente prevedibile che venga superato, quindi è accettabile l'impostazione a priori dei controlli in fase di progettazione, anziché la creazione degli stessi in fase di esecuzione, ma sarebbe più economico segure quest'ultimo sistema.

L'utente compila uno o più caselle combinate, clicca sul pulsante di filtro e la griglia viene riempita con tutti i record che corrispondono ai criteri impostati. L'utente sceglie una riga e clicca sul pulsante di conferma. Viene così composta (e restituita alla form chiamante) una lista di stringhe che rappresentano l'insieme dei valori dei campi chiave necessari all'individuazione di un preciso record.
C'è anche la possibilità di passare alla FrmRicerca un ulteriore parametro ovvero un campo di filtro preventivo che valorizzerà la prima ComboBox disabilitandola all'utente, costituendo quindi un vincolo a qualsiasi filtro l'utente voglia poi applicare. Questa alternativa si rende utile, a esempio, quando, data una banca, si deve cercarne una agenzia.

    Public Function ApriRicerca(ByVal filtro As String) As List(Of String)
      Me.Filtro = filtro
      Me.ShowDialog()
      Return CampiChiave
    End Function

L'autoconfigurazione
La configurazione 'su misura' viene impostata nella gestione dell'evento Load della FrmRicerca, richiamando un metodo diverso per ciascun valore della discriminante TipoRicerca:

  Private Sub FrmRicerca_Load(ByVal sender As System.Object, _
                              ByVal e As System.EventArgs) Handles MyBase.Load
    RigaSelezionata = -1

    Select Case myTiporicerca
      Case "agenzia-clienti", "agenzia-fornitori"
        Agenzie()
      Case "codice-clienti"
        CodCliFor(1)
      Case "codice-fornitori"
        CodCliFor(2)
      Case "codice-prodotti"
        CodProd()
      Case "banche"
        Banche(1)
      Case "clienti"
        Clienti(1)
      Case "fornitori"
        Fornitori(1)
      Case "prodotti"
        Prodotti(1)
      Case "stati"
        Stati(1)
      Case "pagamenti"
        TipiPag(1)
      Case "unita-misura"
        UnMis(1)
      Case "carico-magazzino"
        CarMag(1)
    End Select
  End Sub

Questo è un disastro, dal mio punto di vista. Che funzioni, non c'è alcun dubbio. Ma è anche indubbiamente sbagliato.
Questa non è una configurazione dinamica, ma una scelta tra diverse configurazioni statiche. L'effetto finale è lo stesso, ma il lavoro svolto (e da svolgere) è molto maggiore (da parte del programmatore).

Per ogni nuova form (mettiamo caso che si aggiunga una tabella Veicoli al nostro database) bisogna non solo preparare la form, appunto, ma anche predisporre la 'sua' specifica personalizzazione in questa form, sia aggiungendo un Case al costrutto Select presente nella procedura sopra esposta, sia aggiungendo il metodo dedicato (e tutte le altre modifiche in altri metodi, senza dimenticarsene nessuno).

Bisognerebbe invece sforzarsi di costruire un metodo unico che elabori tutte le informazioni che la form chiamante può passare, distinguendo il meno possibile tra le diverse eventualità. Bisogna cioè analizzare che cosa serve alla FrmRicerca per 'costruirsi'.

I metodi di configurazione
Poiché sono molto simili l'uno all'altro, ne spiegherò estesamente uno, limitandomi a esporre le differenze presentate dagli altri. Il punto di vista non è quello di spiegare il codice, ma di prendere nota di quanto è necessario al funzionamento di FrmRicerca.

  Private Sub Agenzie()
    'Titolo della form
    Me.Text = "Selezione agenzia"
    'Disabilito i pulsanti di filtro e svuotamento dei criteri
    btnFiltra.Enabled = False
    btnSvuota.Enabled = False
    'Impostazione delle label
    lblCrit1.Text = "Banca"
    lblCrit2.Text = ""
    lblCrit2.Visible = False
    lblCrit3.Text = ""
    lblCrit3.Visible = False
    lblCrit4.Text = ""
    lblCrit4.Visible = False
    lblCrit5.Text = ""
    lblCrit5.Visible = False
    lblCrit6.Text = ""
    lblCrit6.Visible = False
    'preparo gli oggetti per valorizzare ComboBox e griglia
    Dim dst As DataSet
    Dim cmd As SqlCommand
    Dim adp As SqlDataAdapter
    Dim cbl As SqlCommandBuilder
    Dim StringaSQL As String = String.Empty

    cmd = New SqlCommand
    cmd.Connection = SqlHelper.ConnessioneDatabase

    cmbCrit1.Text = Filtro
    StringaSQL = "SELECT Banca, Agenzia, ABI, CAB, Citta, Stato " & _
                 "FROM Banche WHERE Banca = @Banca ORDER BY Banca, Agenzia"

    cmd.Parameters.AddWithValue("@Banca", cmbCrit1.Text)
    cmd.Parameters("@Banca").SqlDbType = SqlDbType.VarChar

    cmd.CommandText = StringaSQL

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

    dst = New DataSet

    adp.Fill(dst, "Dati")
    'Valorizzo la ComboBox
    cmbCrit1.DataSource = dst.Tables(0)
    cmbCrit1.DisplayMember = "Banca"
    cmbCrit1.ValueMember = "Banca"
    cmbCrit2.Visible = False
    cmbCrit3.Visible = False
    cmbCrit4.Visible = False
    cmbCrit5.Visible = False
    cmbCrit6.Visible = False
    'Sposto più in alto la griglia
    dgvDati.Top = 100
    'Diminuisco le dimensioni della form
    Me.Height = 478
    'Formattazione della griglia
    'Nomi colonne e intestazioni
    Dim Nomicol() As String = {"Banca", "Agenzia", "ABI", "CAB", "Citta", "Stato"}

    'Aggiungo le colonne
    For i As Integer = 0 To Nomicol.Length - 1
      dgvDati.Columns.Add(Nomicol(i), Nomicol(i))
    Next i
    'Imposto le proprietà delle colonne
    dgvDati.RowHeadersWidth = 20
    dgvDati.Columns(0).DataPropertyName = "Banca"
    dgvDati.Columns(0).Width = 290
    dgvDati.Columns(1).DataPropertyName = "Agenzia"
    dgvDati.Columns(1).Width = 290
    dgvDati.Columns(2).DataPropertyName = "ABI"
    dgvDati.Columns(2).Width = 45
    dgvDati.Columns(3).DataPropertyName = "CAB"
    dgvDati.Columns(3).Width = 45
    dgvDati.Columns(4).DataPropertyName = "Citta"
    dgvDati.Columns(4).HeaderText = "Città"
    dgvDati.Columns(4).Width = 290
    dgvDati.Columns(5).DataPropertyName = "Stato"
    dgvDati.Columns(5).Width = 290
    dgvDati.DefaultCellStyle.NullValue = ""
    'Compilo la griglia
    dgvDati.DataSource = dst
    dgvDati.DataMember = "Dati"
  End Sub

Altri metodi analoghi a questo si differenziano per la presenza di un parametro, che - per giunta - non ha lo stesso significato. A esempio:

  Private Sub CodCliFor(ByVal Tipo As Byte)
    'Tipo può assumere i valori 1 Clienti, 2 Fornitori

    'Titolo della form
    Select Case Tipo
      Case 1
        Me.Text = "Selezione codice cliente"
      Case 2
        Me.Text = "Selezione codice fornitore"
    End Select
  Private Sub Banche(ByVal Completa As Byte)
    'Completa può assumere i valori 1 formattazione completa e 2 formattazione solo della griglia

    If Completa = 1 Then
      'Titolo della form
      Me.Text = "Ricerca banche"
      'Impostazione delle label
      lblCrit1.Text = "Banca"
      lblCrit2.Text = "Agenzia"
      lblCrit3.Text = "ABI"
      lblCrit4.Text = "CAB"
      lblCrit5.Text = "Città"
      lblCrit6.Text = "Stato"
      'Compilazione dei ComboBox
      PopolaCombo(1, "Banca", "Banche")
      PopolaCombo(2, "Agenzia", "Banche")
      PopolaCombo(3, "ABI", "Banche")
      PopolaCombo(4, "CAB", "Banche")
      PopolaCombo(5, "Citta", "Banche")
      PopolaCombo(6, "Stato", "Banche")
      'Svuoto i ComboBox
      SvuotaCombo()
    End If
  Private Sub Alternativa()
    Static once As Boolean

    If Not once Then
      '...
      once = Not once
    End If

Il metodo PopolaCombo richiede il numero della ComboBox e i nomi dei campi da assegnare a DisplayMember e ValueMember.

Nel metodo che configura la FrmRicerca per le tabelle Clienti, Fornitori e Prodotti viene resa invisibile la colonna Codice.

  Private Sub TipiPag(ByVal Completa As Byte)
    'Completa può assumere i valori 1 formattazione completa e 2 formattazione solo della griglia

    If Completa = 1 Then
      'Titolo della form
      Me.Text = "Ricerca tipi di pagamento"
      'Impostazione delle label
      lblCrit1.Text = "Tipo di pagamento"
      lblCrit2.Text = "Fine mese"
      lblCrit3.Text = "Giorno fisso"
      lblCrit4.Text = "Appoggio in banca"
      lblCrit5.Text = ""
      lblCrit5.Visible = False
      lblCrit6.Text = ""
      lblCrit6.Visible = False
      'Compilazione dei ComboBox
      PopolaCombo(1, "Pagamento", "Pagamenti")
      cmbCrit2.Items.Add("No")
      cmbCrit2.Items.Add("Si")
      PopolaCombo(3, "Giorno_fisso", "Pagamenti")
      cmbCrit4.Items.Add("No")
      cmbCrit4.Items.Add("Si")
      cmbCrit5.Visible = False
      cmbCrit6.Visible = False
      'Svuoto i ComboBox
      SvuotaCombo()
    End If
    'Aggiungo le colonne
    Dim ColBoolFM As New DataGridViewCheckBoxColumn
    Dim ColBoolAB As New DataGridViewCheckBoxColumn

    For i As Integer = 0 To Nomicol.Length - 1
      If i = 1 Then
        ColBoolFM.DataPropertyName = "Fine_mese"
        ColBoolFM.HeaderText = "Fine mese"
        dgvDati.Columns.Add(ColBoolFM)
      ElseIf i = 3 Then
        ColBoolAB.DataPropertyName = "Appoggio_banca"
        ColBoolAB.HeaderText = "Appoggio banca"
        dgvDati.Columns.Add(ColBoolAB)
      ElseIf i = 0 Or i = 2 Then
        dgvDati.Columns.Add(Nomicol(i), Nomicol(i))
      End If
    Next i

I metodi di evento dei pulsanti
Se per quanto riguarda la configurazione ci siamo trovati davanti a una serie di metodi molto simili l'uno all'altro, nel codice che gestisce gli eventi Click dei pulsanti BtnFiltra e BtnConferma ci troviamo di fronte al festival della ripetizione della stessa sequenza di istruzioni con lievi variazioni. E' evidente che Oscar, per programmare velocemente, ha fatto più volte copia-incolla. E non ha pensato che si potesse avere un vettore di controlli anche in .Net (non è quasi mai realmente necessario, ma talvolta capita).

Se però pensiamo a quante volte l'ha dovuto fare, ci possiamo rendere conto che 'pensare prima fa fare prima' e meglio.
Ecco la prima parte del codice di BtnFiltra_Click, da me resa un pochino più leggibile, aggiungendo qualche 'a capo':

  Private Sub btnFiltra_Click(ByVal sender As System.Object, _
                              ByVal e As System.EventArgs) Handles btnFiltra.Click
    Dim dst As DataSet
    Dim cmd As SqlCommand
    Dim adp As SqlDataAdapter
    Dim cbl As SqlCommandBuilder
    Dim StringaSQL As String = String.Empty

    cmd = New SqlCommand
    cmd.Connection = SqlHelper.ConnessioneDatabase
    Select Case myTiporicerca
      Case "banche"
        StringaSQL = "SELECT Banca, Agenzia, Citta, Stato FROM Banche"
        If cmbCrit1.Text.Trim <> String.Empty Or cmbCrit2.Text.Trim <> String.Empty Or _
           cmbCrit3.Text.Trim <> String.Empty Or cmbCrit4.Text.Trim <> String.Empty Or _
           cmbCrit5.Text.Trim <> String.Empty Or cmbCrit6.Text.Trim <> String.Empty Then

          StringaSQL = StringaSQL & " WHERE "
        End If

        If cmbCrit1.Text.Trim <> String.Empty Then
          StringaSQL = StringaSQL & "Banca = @Banca"
          cmd.Parameters.AddWithValue("@Banca", cmbCrit1.Text)
          cmd.Parameters("@Banca").SqlDbType = SqlDbType.VarChar
        End If

        If cmbCrit2.Text.Trim <> String.Empty Then
          If cmbCrit1.Text.Trim <> String.Empty Then
            StringaSQL = StringaSQL & " AND "
          End If
          StringaSQL = StringaSQL & "Agenzia = @Agenzia"
          cmd.Parameters.AddWithValue("@Agenzia", cmbCrit2.Text)
          cmd.Parameters("@Agenzia").SqlDbType = SqlDbType.VarChar
        End If

        If cmbCrit3.Text.Trim <> String.Empty Then
          If cmbCrit1.Text.Trim <> String.Empty Or cmbCrit2.Text.Trim <> String.Empty Then
            StringaSQL = StringaSQL & " AND "
          End If
          StringaSQL = StringaSQL & "ABI = @ABI"
          cmd.Parameters.AddWithValue("@ABI", cmbCrit3.Text)
          cmd.Parameters("@ABI").SqlDbType = SqlDbType.VarChar
        End If

        If cmbCrit4.Text.Trim <> String.Empty Then
          If cmbCrit1.Text.Trim <> String.Empty Or _
             cmbCrit2.Text.Trim <> String.Empty Or cmbCrit3.Text.Trim <> String.Empty Then

            StringaSQL = StringaSQL & " AND "
          End If
          StringaSQL = StringaSQL & "CAB = @CAB"
          cmd.Parameters.AddWithValue("@CAB", cmbCrit4.Text)
          cmd.Parameters("@CAB").SqlDbType = SqlDbType.VarChar
        End If

        If cmbCrit5.Text.Trim <> String.Empty Then
          If cmbCrit1.Text.Trim <> String.Empty Or cmbCrit2.Text.Trim <> String.Empty Or _
             cmbCrit3.Text.Trim <> String.Empty Or cmbCrit4.Text.Trim <> String.Empty Then

            StringaSQL = StringaSQL & " AND "
          End If
          StringaSQL = StringaSQL & "Citta = @Citta"
          cmd.Parameters.AddWithValue("@Citta", cmbCrit5.Text)
          cmd.Parameters("@Citta").SqlDbType = SqlDbType.VarChar
        End If
        If cmbCrit6.Text.Trim <> String.Empty Then
          If cmbCrit1.Text.Trim <> String.Empty Or cmbCrit2.Text.Trim <> String.Empty Or _
             cmbCrit3.Text.Trim <> String.Empty Or cmbCrit4.Text.Trim <> String.Empty Or _
             cmbCrit5.Text.Trim <> String.Empty Then

            StringaSQL = StringaSQL & " AND "
          End If
          StringaSQL = StringaSQL & "Stato = @Stato"
          cmd.Parameters.AddWithValue("@Stato", cmbCrit6.Text)
          cmd.Parameters("@Stato").SqlDbType = SqlDbType.VarChar
        End If
        StringaSQL = StringaSQL & " ORDER BY Banca, Agenzia"

Come potete vedere, si controlla il Text di ciascuna ComboBox per costruire la clausola WHERE dell'espressione SQL del comando che leggerà di dati da visualizzare nella griglia e per impostare i parametri del comando stesso.
Se si pensasse all'espressione query come a una struttura costituita da tre strutture (selezione, criteri, ordinamento), verrebbe naturale separarle. In particolare sarebbe semplice costruire 'a parte' il contenuto della clausola WHERE, aggiungendo criteri e parametri per ciascuna ComboBox non vuota, senza preoccuparsi delle altre. A esempio, se si aggiunge a una stringa clausolaWhere l'operatore " AND " e il criterio "nomecampo =@nomecampo", ci si ritrova alla fine con la clausolaWhere completa, solo che comincia con " AND", parte da togliere e sostituire con "WHERE". Molto più economico di tante If.

Segue una copia conforme del codice sopraesposto per ciascuna configurazione, per un totale di più di trecento righe di istruzioni.

Mi permetto di proporvi uno dei tanti acronimi per cui gli Americani vanno pazzi, perché, una volta tanto, assume un significato facilmente memorizzabile:  DRY  . E' l'acronimo di Don't Repeat Yourself (non ripeterti), che è una delle massime auree del programmatore.

Il fenomeno del copia-incolla di Oscar (ribadisco che la critica è più estetica che altro: ricordiamoci bene che il codice, nonostante sembri così brutto, è perfettamente funzionante - un difetto pratico sta solo nella confusione, nella difficoltà di manutenzione) si ripete nel metodo btnConferma_Click:

  Private Sub btnConferma_Click(ByVal sender As System.Object, _
                                ByVal e As System.EventArgs) Handles btnConferma.Click

    If dgvDati.Rows.Count < 1 Then
      Exit Sub
    End If

    Dim Riga As DataGridViewRow = dgvDati.CurrentCell.OwningRow

    If RigaSelezionata < 0 Then
      Messaggi.Avviso("Selezionare una riga cliccando nella colonna fissa all'estrema " & _
                      "sinistra della stessa per proseguire con la ricerca", "Selezione riga")
    Else
      Select Case myTiporicerca
        Case "agenzia-clienti", "agenzia-fornitori"
          If Not (Riga.Cells("Banca").Value) Is Nothing Then
            If Riga.Cells("Banca").Value Is DBNull.Value Then
              Messaggi.Avviso("Selezionare una riga cliccando nella colonna fissa all'estrema " & _
                            "sinistra della stessa per proseguire con la ricerca", "Selezione riga")
            Else
              CampiChiave.Add(Riga.Cells("Banca").Value.ToString)
              CampiChiave.Add(Riga.Cells("Agenzia").Value.ToString)
              CampiChiave.Add(Riga.Cells("ABI").Value.ToString)
              CampiChiave.Add(Riga.Cells("CAB").Value.ToString)
              Me.Close()
            End If
          Else
            Messaggi.Avviso("Selezionare una riga cliccando nella colonna fissa all'estrema " & _
                            "sinistra della stessa per proseguire con la ricerca", "Selezione riga")
          End If

Come potete vedere, si controlla che esista un record congruo da leggere e lo si legge per popolare la lista CampiChiave da restituire alla form chiamante. Salta all'occhio che si risparmierebbe un po' di fatica e si eliminerebbe un po' di confusione impostando una costante per il messaggio. Anche qui, il codice si ripete per ciascuna configurazione, per un totale di più di centocinquanta righe di istruzioni.

Entrambi questi ultimi due metodi sono abbastanza facilmente generalizzabili, dato che analizzano sempre il valore presente nella prima colonna e restituiscono sempre una lista di campi chiave, i cui nomi conosciamo già dalla classe Tabella.

Conclusione
In questo articolo Diego ha criticato l'impostazione di Oscar della FrmRicerca, illustrando quelli che considera difetti e preparando l'analisi che servirà a sviluppare codice migliore.

Precisando che Oscar è completamente d'accordo con le critiche di Diego e sulla loro pubblicazione nell'intento di far imparare anche gli altri dal suo errore di impostazione, chiaramente residuo della mentalità VB6, si dà appuntamento alla prossima puntata, in cui verrà illustrata la 'vera' FrmRicerca.

Stavolta non è stato aggiunto codice, quindi non c'è alcunché da scaricare, né troverete nel blog un companion per questo articolo, ma colgo l'occasione per segnalare l'ottima variazione proposta da Edoardo Casella, relativamente al metodo GetSchema della classe Tabella.