Database
 sql >> Base de données >  >> RDS >> Database

Analyser les valeurs par défaut des paramètres à l'aide de PowerShell - Partie 1

[ Partie 1 | Partie 2 | Partie 3 ]

Si vous avez déjà essayé de déterminer les valeurs par défaut des paramètres de procédure stockée, vous avez probablement des marques sur le front après l'avoir frappé de manière répétée et violente sur votre bureau. La plupart des articles qui parlent de récupérer des informations sur les paramètres (comme cette astuce) ne mentionnent même pas le mot par défaut. En effet, à l'exception du texte brut stocké dans la définition de l'objet, les informations ne se trouvent nulle part dans les vues du catalogue. Il y a des colonnes has_default_value et default_value dans sys.parameters ça regarde prometteurs, mais ils ne sont remplis que pour les modules CLR.

Dériver des valeurs par défaut à l'aide de T-SQL est fastidieux et sujet aux erreurs. J'ai récemment répondu à une question sur Stack Overflow à propos de ce problème, et cela m'a ramené dans le passé. En 2006, je me suis plaint via plusieurs éléments Connect du manque de visibilité des valeurs par défaut des paramètres dans les vues du catalogue. Cependant, le problème existe toujours dans SQL Server 2019. (Voici le seul élément que j'ai trouvé qui a été intégré au nouveau système de commentaires.)

Bien qu'il soit gênant que les valeurs par défaut ne soient pas exposées dans les métadonnées, elles ne le sont probablement pas car il est difficile de les analyser à partir du texte de l'objet (dans n'importe quel langage, mais en particulier dans T-SQL). Il est même difficile de trouver le début et la fin de la liste des paramètres car la capacité d'analyse de T-SQL est si limitée et il y a plus de cas extrêmes que vous ne pouvez l'imaginer. Quelques exemples :

  • Vous ne pouvez pas compter sur la présence de ( et ) pour indiquer la liste des paramètres, car ils sont facultatifs (et peuvent être trouvés dans la liste des paramètres)
  • Vous ne pouvez pas facilement analyser le premier AS pour marquer le début du corps, car il peut apparaître pour d'autres raisons
  • Vous ne pouvez pas compter sur la présence de BEGIN pour marquer le début du corps, puisqu'il est facultatif
  • Il est difficile de séparer les virgules, car elles peuvent apparaître dans les commentaires, dans les littéraux de chaîne et dans le cadre des déclarations de type de données (pensez à (precision, scale) )
  • Il est très difficile d'analyser les deux types de commentaires, qui peuvent apparaître n'importe où (y compris à l'intérieur de chaînes littérales) et peuvent être imbriqués
  • Vous pouvez trouver par inadvertance des mots-clés importants, des virgules et des signes égal dans les littéraux de chaîne et les commentaires
  • Vous pouvez avoir des valeurs par défaut qui ne sont pas des nombres ou des littéraux de chaîne (pensez à {fn curdate()} ou GETDATE )

Il y a tellement de petites variations de syntaxe que les techniques normales d'analyse de chaînes sont rendues inefficaces. Ai-je vu AS déjà? Était-ce entre un nom de paramètre et un type de données ? Était-ce après une parenthèse droite qui entoure toute la liste des paramètres, ou [une ?] qui n'avait pas de correspondance avant la dernière fois que j'ai vu un paramètre ? Cette virgule sépare-t-elle deux paramètres ou fait-elle partie de la précision et de l'échelle ? Lorsque vous parcourez une chaîne un mot à la fois, cela continue encore et encore, et il y a tellement de bits que vous devez suivre.

Prenons cet exemple (intentionnellement ridicule, mais toujours syntaxiquement valide) :

/* 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;

L'analyse des valeurs par défaut de cette définition à l'aide de T-SQL est difficile. Vraiment difficile . Sans BEGIN pour marquer correctement la fin de la liste des paramètres, tout le désordre des commentaires et tous les cas où des mots-clés comme AS peut signifier différentes choses, vous aurez probablement un ensemble complexe d'expressions imbriquées impliquant plus de SUBSTRING et CHARINDEX modèles que vous n'avez jamais vus au même endroit auparavant. Et vous vous retrouverez probablement toujours avec @d et @e ressemblant à des paramètres de procédure au lieu de variables locales.

En réfléchissant un peu plus au problème et en cherchant à voir si quelqu'un avait réussi quelque chose de nouveau au cours de la dernière décennie, je suis tombé sur cet excellent article de Michael Swart. Dans cet article, Michael utilise le TSqlParser de ScriptDom pour supprimer les commentaires sur une seule ligne et sur plusieurs lignes d'un bloc de T-SQL. J'ai donc écrit du code PowerShell pour suivre une procédure permettant de voir quels autres jetons ont été identifiés. Prenons un exemple plus simple sans tous les problèmes intentionnels :

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

Ouvrez Visual Studio Code (ou votre IDE PowerShell préféré) et enregistrez un nouveau fichier appelé Test1.ps1. La seule condition préalable est d'avoir la dernière version de Microsoft.SqlServer.TransactSql.ScriptDom.dll (que vous pouvez télécharger et extraire de sqlpackage ici) dans le même dossier que le fichier .ps1. Copiez ce code, enregistrez-le, puis exécutez-le ou déboguez :

# 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)";
        }
    }
}

Les résultats :

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

Créer :CREATE
Espace Blanc :
Procédure :PROCEDURE
Espace Blanc :
Identifiant :dbo
Point :.
Identifiant :procedure1
Espace Blanc :
Espace Blanc :
Variable :@param1
Espace Blanc :
As :AS
Espace Blanc :
Identifiant :int
Espace Blanc :
As :AS
WhiteSpace :
Print :PRINT
WhiteSpace :
Entier :1
Point-virgule :;
WhiteSpace :
Go :GO
FinFichier :

Pour éliminer une partie du bruit, nous pouvons filtrer quelques TokenTypes à l'intérieur de la dernière boucle for :

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

Pour finir avec une série de jetons plus concise :

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

Créer :CREATE
Procédure :PROCEDURE
Identifiant :dbo
Point :.
Identifiant :procedure1
Variable :@param1
As :AS
Identifiant :int
As :AS
Print :PRINT
Entier :1

La façon dont cela correspond visuellement à une procédure :

Chaque jeton analysé à partir de ce corps de procédure simple.

Vous pouvez déjà voir les problèmes que nous aurons en essayant de reconstruire les noms de paramètres, les types de données et même de trouver la fin de la liste des paramètres. Après avoir examiné cela un peu plus, je suis tombé sur un article de Dan Guzman qui mettait en évidence une classe ScriptDom appelée TSqlFragmentVisitor, qui identifie les fragments d'un bloc de T-SQL analysé. Si nous changeons un peu la tactique, nous pouvons inspecter des fragments au lieu de jetons . Un fragment est essentiellement un ensemble d'un ou plusieurs jetons et possède également sa propre hiérarchie de types. Pour autant que je sache, il n'y a pas de ScriptFragmentStream pour parcourir les fragments, mais nous pouvons utiliser un Visitor modèle pour faire essentiellement la même chose. Créons un nouveau fichier appelé Test2.ps1, collez-y ce code et exécutez-le :

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;
   }
}

Résultats (intéressants pour cet exerciceen gras ):

TSqlScript
TSqlBatch
CreateProcedureStatement
ProcedureReference
SchemaObjectName
Identifier
Identifier
ProcedureParameter
Identifier
SqlDataTypeReference
SchemaObjectName
Identifier
StatementList
PrintStatement
IntegerLiteral

Si nous essayons de mapper cela visuellement sur notre diagramme précédent, cela devient un peu plus complexe. Chacun de ces fragments est lui-même un flux d'un ou plusieurs jetons, et parfois ils se chevauchent. Plusieurs jetons d'instruction et mots-clés ne sont même pas reconnus seuls dans le cadre d'un fragment, comme CREATE , PROCEDURE , AS , et GO . Ce dernier est compréhensible puisqu'il ne s'agit même pas du tout de T-SQL, mais l'analyseur doit toujours comprendre qu'il sépare les lots.

Comparaison de la manière dont les jetons d'instruction et les jetons de fragment sont reconnus.

Pour reconstruire n'importe quel fragment de code, nous pouvons parcourir ses jetons lors d'une visite à ce fragment. Cela nous permet de dériver des choses comme le nom de l'objet et les fragments de paramètres avec une analyse et des conditions beaucoup moins fastidieuses, bien que nous devions toujours boucler à l'intérieur du flux de jetons de chaque fragment. Si nous changeons Write-Host $fragment.GetType().Name; dans le script précédent à ceci :

[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;
  }
}

La sortie est :

=========================
ProcédureRéférence
==========================

dbo.procedure1

=========================
ParamètreProcédure
==========================

@param1 AS entier

Nous avons l'objet et le nom du schéma ensemble sans avoir à effectuer d'itération ou de concaténation supplémentaire. Et nous avons toute la ligne impliquée dans toute déclaration de paramètre, y compris le nom du paramètre, le type de données et toute valeur par défaut qui pourrait exister. Fait intéressant, le visiteur gère @param1 int et int comme deux fragments distincts, essentiellement en comptant deux fois le type de données. Le premier est un ProcedureParameter fragment, et ce dernier est un SchemaObjectName . Nous ne nous soucions vraiment que du premier SchemaObjectName référence (dbo.procedure1 ) ou, plus précisément, uniquement celui qui suit ProcedureReference . Je promets que nous nous en occuperons, mais pas tous aujourd'hui. Si nous modifions la $procedure constante à ceci (en ajoutant un commentaire et une valeur par défaut) :

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

La sortie devient alors :

=========================
ProcédureRéférence
==========================

dbo.procedure1

=========================
ParamètreProcédure
==========================

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

Cela inclut toujours tous les jetons dans la sortie qui sont en fait des commentaires. À l'intérieur de la boucle for, nous pouvons filtrer tous les types de jetons que nous voulons ignorer afin de résoudre ce problème (je supprime également les AS superflus mots-clés dans cet exemple, mais vous ne voudrez peut-être pas le faire si vous reconstruisez des corps de module) :

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

La sortie est plus propre, mais toujours pas parfaite.

=========================
ProcédureRéférence
==========================

dbo.procedure1

=========================
ParamètreProcédure
==========================

@param1 entier =-64

Si nous voulons séparer le nom du paramètre, le type de données et la valeur par défaut, cela devient plus complexe. Pendant que nous parcourons le flux de jetons pour un fragment donné, nous pouvons séparer le nom du paramètre de toutes les déclarations de type de données en suivant simplement le moment où nous atteignons un EqualsSign jeton. Remplacement de la boucle for par cette logique supplémentaire :

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;
}

Maintenant, la sortie est :

=========================
ProcédureRéférence
==========================

dbo.procedure1

=========================
ParamètreProcédure
==========================

Nom du paramètre :@param1
Type de paramètre :int
Par défaut :-64

C'est mieux, mais il y a encore plus à résoudre. Il y a des mots-clés de paramètres que j'ai ignorés jusqu'à présent, comme OUTPUT et READONLY , et nous avons besoin de logique lorsque notre entrée est un lot avec plus d'une procédure. Je traiterai de ces problèmes dans la partie 2.

En attendant, expérimentez ! Il y a beaucoup d'autres choses puissantes que vous pouvez faire avec ScriptDOM, TSqlParser et TSqlFragmentVisitor.

[ Partie 1 | Partie 2 | Partie 3 ]