Database
 sql >> Database >  >> RDS >> Database

Analizza i valori predefiniti dei parametri usando PowerShell – Parte 1

[ Parte 1 | Parte 2 | Parte 3]

Se hai mai provato a determinare i valori predefiniti per i parametri della procedura memorizzata, probabilmente hai dei segni sulla fronte per averla colpita sulla scrivania ripetutamente e violentemente. La maggior parte degli articoli che parlano del recupero delle informazioni sui parametri (come questo suggerimento) non menzionano nemmeno la parola default. Questo perché, fatta eccezione per il testo grezzo memorizzato nella definizione dell'oggetto, le informazioni non si trovano da nessuna parte nelle viste del catalogo. Ci sono colonne has_default_value e default_value in sys.parameters quel aspetto promettente, ma sono sempre popolati solo per moduli CLR.

La derivazione di valori predefiniti utilizzando T-SQL è ingombrante e soggetta a errori. Di recente ho risposto a una domanda su Stack Overflow su questo problema e mi ha portato indietro nella memoria. Nel 2006, mi sono lamentato tramite più elementi Connect della mancanza di visibilità dei valori predefiniti per i parametri nelle viste del catalogo. Tuttavia, il problema persiste in SQL Server 2019. (Ecco l'unico elemento che ho trovato che è arrivato al nuovo sistema di feedback.)

Sebbene sia un inconveniente che i valori predefiniti non siano esposti nei metadati, molto probabilmente non sono presenti perché analizzarli dal testo dell'oggetto (in qualsiasi lingua, ma in particolare in T-SQL) è difficile. È difficile persino trovare l'inizio e la fine dell'elenco dei parametri perché la capacità di analisi di T-SQL è così limitata e ci sono più casi limite di quanto tu possa immaginare. Alcuni esempi:

  • Non puoi fare affidamento sulla presenza di ( e ) per indicare l'elenco dei parametri, poiché sono opzionali (e possono essere trovati in tutto l'elenco dei parametri)
  • Non puoi analizzare facilmente il primo AS per segnare l'inizio del corpo, poiché può apparire per altri motivi
  • Non puoi fare affidamento sulla presenza di BEGIN per segnare l'inizio del corpo, poiché è facoltativo
  • È difficile dividere le virgole, poiché possono apparire all'interno dei commenti, all'interno di stringhe letterali e come parte delle dichiarazioni del tipo di dati (pensa a (precision, scale) )
  • È molto difficile analizzare entrambi i tipi di commenti, che possono apparire ovunque (anche all'interno di stringhe letterali) e possono essere nidificati
  • Puoi trovare inavvertitamente parole chiave, virgole e segni di uguale importanti all'interno di stringhe letterali e commenti
  • Puoi avere valori predefiniti che non siano numeri o stringhe letterali (pensa a {fn curdate()} o GETDATE )

Ci sono così tante piccole variazioni di sintassi che le normali tecniche di analisi delle stringhe sono rese inefficaci. Ho visto AS già? Era tra un nome di parametro e un tipo di dati? Era dopo una parentesi destra che circonda l'intero elenco di parametri o [uno?] che non aveva una corrispondenza prima dell'ultima volta che ho visto un parametro? Quella virgola separa due parametri o fa parte della precisione e della scala? Quando stai scorrendo una stringa una parola alla volta, va avanti all'infinito e ci sono così tanti bit che devi tenere traccia.

Prendi questo esempio (intenzionalmente ridicolo, ma sintatticamente valido):

/* AS BEGIN , @a int = 7, comments can appear anywhere */
CREATE PROCEDURE dbo.some_procedure 
  -- AS BEGIN, @a int = 7 'blat' AS =
  /* AS BEGIN, @a int = 7 'blat' AS = -- */
  @a AS /* comment here because -- chaos */ int = 5,
  @b AS varchar(64) = 'AS = /* BEGIN @a, int = 7 */ ''blat''',
  @c AS int = -- 12 
              6 
AS
    -- @d int = 72,
    DECLARE @e int = 5;
    SET @e = 6;

Analizzare i valori predefiniti da quella definizione utilizzando T-SQL è difficile. Davvero difficile . Senza BEGIN per contrassegnare correttamente la fine dell'elenco dei parametri, tutto il pasticcio dei commenti e tutti i casi in cui parole chiave come AS può significare cose diverse, probabilmente avrai un insieme complesso di espressioni nidificate che coinvolgono più SUBSTRING e CHARINDEX modelli che non hai mai visto in un posto prima. E probabilmente finirai ancora con @d e @e sembrano parametri di procedura invece di variabili locali.

Pensando ancora un po' al problema e cercando di vedere se qualcuno fosse riuscito a fare qualcosa di nuovo nell'ultimo decennio, mi sono imbattuto in questo fantastico post di Michael Swart. In quel post, Michael usa TSqlParser di ScriptDom per rimuovere commenti sia a riga singola che a più righe da un blocco di T-SQL. Quindi ho scritto del codice PowerShell per eseguire una procedura per vedere quali altri token sono stati identificati. Facciamo un esempio più semplice senza tutti i problemi intenzionali:

CREATE PROCEDURE dbo.procedure1
  @param1 int
AS PRINT 1;
GO

Apri Visual Studio Code (o il tuo IDE PowerShell preferito) e salva un nuovo file chiamato Test1.ps1. L'unico prerequisito è avere l'ultima versione di Microsoft.SqlServer.TransactSql.ScriptDom.dll (che puoi scaricare ed estrarre da sqlpackage qui) nella stessa cartella del file .ps1. Copia questo codice, salva, quindi esegui o esegui il debug:

# need to extract this DLL from latest sqlpackage; place it in same folder
# https://docs.microsoft.com/en-us/sql/tools/sqlpackage-download
Add-Type -Path "Microsoft.SqlServer.TransactSql.ScriptDom.dll";
 
# set up a parser object using the most recent version available 
$parser = [Microsoft.SqlServer.TransactSql.ScriptDom.TSql150Parser]($true)::New(); 
 
# and an error collector
$errors = [System.Collections.Generic.List[Microsoft.SqlServer.TransactSql.ScriptDom.ParseError]]::New();
 
# this ultimately won't come from a constant - think file, folder, database
# can be a batch or multiple batches, just keeping it simple to start
 
$procedure = @"
CREATE PROCEDURE dbo.procedure1
  @param1 AS int
AS PRINT 1;
GO
"@
 
# now we need to try parsing
$block = $parser.Parse([System.IO.StringReader]::New($procedure), [ref]$errors);
 
# parse the whole thing, which is a set of one or more batches
foreach ($batch in $block.Batches)
{
    # each batch contains one or more statements
    # (though a valid create procedure statement is also always just one batch)
    foreach ($statement in $batch.Statements)
    {
        # output the type of statement
        Write-Host "  ====================================";
        Write-Host "    $($statement.GetType().Name)";
        Write-Host "  ====================================";        
 
        # each statement has one or more tokens in its token stream
        foreach ($token in $statement.ScriptTokenStream)
        {
            # those tokens have properties to indicate the type
            # as well as the actual text of the token
            Write-Host "  $($token.TokenType.ToString().PadRight(16)) : $($token.Text)";
        }
    }
}

I risultati:

=====================================
CreateProcedureStatement
======================================

Crea :CREATE
Spazio bianco :
Procedura :PROCEDURA
Spazio bianco :
Identificatore :dbo
Punto :.
Identificatore :procedura1
Spazio bianco :
WhiteSpace :
Variabile :@param1
WhiteSpace :
As :AS
WhiteSpace :
Identificatore :int
WhiteSpace :
As :AS
WhiteSpace :
Print :PRINT
WhiteSpace :
Intero :1
Punto e virgola :;
WhiteSpace :
Vai :GO
/>FileFine:

Per eliminare parte del rumore, possiamo filtrare alcuni TokenType all'interno dell'ultimo ciclo for:

      foreach ($token in $statement.ScriptTokenStream)
      {
         if ($token.TokenType -notin "WhiteSpace", "Go", "EndOfFile", "SemiColon")
         {
           Write-Host "  $($token.TokenType.ToString().PadRight(16)) : $($token.Text)";
         }
      }

Per finire con una serie più concisa di token:

=====================================
CreateProcedureStatement
======================================

Crea :CREATE
Procedura :PROCEDURE
Identificatore :dbo
Punto :.
Identificatore :procedura1
Variabile :@param1
As :AS
Identificatore :int
As :AS
Stampa :PRINT
Intero :1

Il modo in cui questo si associa a una procedura visivamente:

Ogni token è stato analizzato da questo semplice corpo della procedura.

Puoi già vedere i problemi che avremo provando a ricostruire i nomi dei parametri, i tipi di dati e persino a trovare la fine dell'elenco dei parametri. Dopo aver esaminato ulteriormente questo aspetto, mi sono imbattuto in un post di Dan Guzman che evidenziava una classe ScriptDom chiamata TSqlFragmentVisitor, che identifica frammenti di un blocco di T-SQL analizzato. Se cambiamo un po' la tattica, possiamo ispezionare frammenti invece di token . Un frammento è essenzialmente un insieme di uno o più token e ha anche una propria gerarchia di tipi. Per quanto ne so, non esiste ScriptFragmentStream per scorrere i frammenti, ma possiamo usare un Visitatore modello per fare essenzialmente la stessa cosa. Creiamo un nuovo file chiamato Test2.ps1, incolliamo questo codice ed eseguiamolo:

Add-Type -Path "Microsoft.SqlServer.TransactSql.ScriptDom.dll";
 
$parser = [Microsoft.SqlServer.TransactSql.ScriptDom.TSql150Parser]($true)::New(); 
 
$errors = [System.Collections.Generic.List[Microsoft.SqlServer.TransactSql.ScriptDom.ParseError]]::New();
 
$procedure = @"
CREATE PROCEDURE dbo.procedure1
  @param1 AS int
AS PRINT 1;
GO
"@
 
$fragment = $parser.Parse([System.IO.StringReader]::New($procedure), [ref]$errors);
$visitor = [Visitor]::New();
$fragment.Accept($visitor);
 
class Visitor: Microsoft.SqlServer.TransactSql.ScriptDom.TSqlFragmentVisitor 
{
   [void]Visit ([Microsoft.SqlServer.TransactSql.ScriptDom.TSqlFragment] $fragment)
   {
       Write-Host $fragment.GetType().Name;
   }
}

Risultati (interessanti per questo esercizio in grassetto ):

TSqlScript
TSqlBatch
CreateProcedureStatement
Riferimento procedura
SchemaObjectName
Identificatore
Identificatore
ProcedureParameter
Identificatore
SqlDataTypeReference
SchemaObjectName
Identificatore
StatementList
PrintStatement
IntegerLiteral

Se proviamo a mappare visivamente questo al nostro diagramma precedente, diventa un po' più complesso. Ciascuno di questi frammenti è esso stesso un flusso di uno o più token e talvolta si sovrapporranno. Diversi token di istruzioni e parole chiave non vengono nemmeno riconosciuti da soli come parte di un frammento, come CREATE , PROCEDURE , AS e GO . Quest'ultimo è comprensibile poiché non è nemmeno T-SQL, ma il parser deve comunque capire che separa i batch.

Confronto del modo in cui vengono riconosciuti i token di istruzione e i token di frammento.

Per ricostruire qualsiasi frammento nel codice, possiamo scorrere i suoi token durante una visita a quel frammento. Questo ci consente di derivare cose come il nome dell'oggetto e i frammenti di parametro con analisi e condizionali molto meno noiosi, anche se dobbiamo ancora scorrere all'interno del flusso di token di ciascun frammento. Se cambiamo Write-Host $fragment.GetType().Name; nello script precedente a questo:

[void]Visit ([Microsoft.SqlServer.TransactSql.ScriptDom.TSqlFragment] $fragment)
{
  if ($fragment.GetType().Name -in ("ProcedureParameter", "ProcedureReference"))
  {
    $output = "";
    Write-Host "==========================";
    Write-Host "  $($fragment.GetType().Name)";
    Write-Host "==========================";
 
    for ($i = $fragment.FirstTokenIndex; $i -le $fragment.LastTokenIndex; $i++)
    {
      $token = $fragment.ScriptTokenStream[$i];
      $output += $token.Text;
    }
    Write-Host $output;
  }
}

L'output è:

===========================
Riferimento procedura
===========================

dbo.procedure1

===========================
Parametro procedura
===========================

@param1 AS int

Abbiamo l'oggetto e il nome dello schema insieme senza dover eseguire ulteriori iterazioni o concatenazioni. E abbiamo l'intera riga coinvolta in qualsiasi dichiarazione di parametro, incluso il nome del parametro, il tipo di dati e qualsiasi valore predefinito che potrebbe esistere. È interessante notare che il visitatore gestisce @param1 int e int come due frammenti distinti, essenzialmente contando due volte il tipo di dati. Il primo è un ProcedureParameter frammento e quest'ultimo è un SchemaObjectName . Ci interessa davvero solo il primo SchemaObjectName riferimento (dbo.procedure1 ) o, più precisamente, solo quello che segue ProcedureReference . Prometto che ce ne occuperemo, ma non tutti oggi. Se cambiamo la $procedure costante a questo (aggiungendo un commento e un valore predefinito):

$procedure = @"
CREATE PROCEDURE dbo.procedure1
  @param1 AS int = /* comment */ -64
AS PRINT 1;
GO
"@

Quindi l'output diventa:

===========================
Riferimento procedura
===========================

dbo.procedure1

===========================
Parametro procedura
===========================

@param1 AS int =/* commento */ -64

Ciò include ancora tutti i token nell'output che sono effettivamente commenti. All'interno del ciclo for, possiamo filtrare tutti i tipi di token che vogliamo ignorare per risolvere questo problema (rimosso anche AS superfluo parole chiave in questo esempio, ma potresti non volerlo fare se stai ricostruendo i corpi dei moduli):

for ($i = $fragment.FirstTokenIndex; $i -le $fragment.LastTokenIndex; $i++)
{
  $token = $fragment.ScriptTokenStream[$i];
  if ($token.TokenType -notin ("MultiLineComment", "SingleLineComment", "As"))
  {
    $output += $token.Text;
  }
}

L'output è più pulito, ma non ancora perfetto.

===========================
Riferimento procedura
===========================

dbo.procedure1

===========================
Parametro procedura
===========================

@param1 int =-64

Se vogliamo separare il nome del parametro, il tipo di dati e il valore predefinito, diventa più complesso. Mentre stiamo scorrendo il flusso di token per un dato frammento, possiamo dividere il nome del parametro da qualsiasi dichiarazione del tipo di dati semplicemente monitorando quando colpiamo un EqualsSign gettone. Sostituzione del ciclo for con questa logica aggiuntiva:

if ($fragment.GetType().Name -in ("ProcedureParameter","SchemaObjectName"))
{
    $output  = "";
    $param   = ""; 
    $type    = "";
    $default = "";
    $seenEquals = $false;
 
      for ($i = $fragment.FirstTokenIndex; $i -le $fragment.LastTokenIndex; $i++)
      {
        $token = $fragment.ScriptTokenStream[$i];
        if ($token.TokenType -notin ("MultiLineComment", "SingleLineComment", "As"))
        {
          if ($fragment.GetType().Name -eq "ProcedureParameter")
          {
            if (!$seenEquals)
            {
              if ($token.TokenType -eq "EqualsSign") 
              { 
                $seenEquals = $true; 
              }
              else 
              { 
                if ($token.TokenType -eq "Variable") 
                {
                  $param += $token.Text; 
                }
                else 
                {
                  $type += $token.Text; 
                }
              }
            }
            else
            { 
              if ($token.TokenType -ne "EqualsSign")
              {
                $default += $token.Text; 
              }
            }
          }
          else 
          {
            $output += $token.Text.Trim(); 
          }
        }
      }
 
      if ($param.Length   -gt 0) { $output  = "Param name: "   + $param.Trim(); }
      if ($type.Length    -gt 0) { $type    = "`nParam type: " + $type.Trim(); }
      if ($default.Length -gt 0) { $default = "`nDefault:    " + $default.TrimStart(); }
      Write-Host $output $type $default;
}

Ora l'output è:

===========================
Riferimento procedura
===========================

dbo.procedure1

===========================
Parametro procedura
===========================

Nome parametro:@param1
Tipo parametro:int
Predefinito:-64

È meglio, ma c'è ancora molto da risolvere. Ci sono parole chiave dei parametri che ho ignorato finora, come OUTPUT e READONLY e abbiamo bisogno di logica quando il nostro input è un batch con più di una procedura. Tratterò questi problemi nella parte 2.

Nel frattempo, sperimenta! Ci sono molte altre cose potenti che puoi fare con ScriptDOM, TSqlParser e TSqlFragmentVisitor.

[ Parte 1 | Parte 2 | Parte 3]