Database
 sql >> Base de Dados >  >> RDS >> Database

Analisar valores padrão de parâmetro usando o PowerShell – Parte 1


[ Parte 1 | Parte 2 | Parte 3]

Se você já tentou determinar os valores padrão para os parâmetros do procedimento armazenado, provavelmente tem marcas na testa ao bater na mesa repetidamente e com violência. A maioria dos artigos que falam sobre a recuperação de informações de parâmetros (como esta dica) nem sequer mencionam a palavra default. Isso ocorre porque, exceto pelo texto bruto armazenado na definição do objeto, as informações não estão em nenhum lugar nas visualizações do catálogo. Existem colunas has_default_value e default_value em sys.parameters aquele olhar promissor, mas eles só são preenchidos para módulos CLR.

Derivar valores padrão usando T-SQL é complicado e propenso a erros. Recentemente, respondi uma pergunta no Stack Overflow sobre esse problema e isso me levou ao passado. Em 2006, reclamei por meio de vários itens do Connect sobre a falta de visibilidade dos valores padrão dos parâmetros nas exibições do catálogo. No entanto, o problema ainda existe no SQL Server 2019. (Aqui está o único item que encontrei que chegou ao novo sistema de feedback.)

Embora seja um inconveniente que os valores padrão não sejam expostos nos metadados, eles provavelmente não estão lá porque analisá-los fora do texto do objeto (em qualquer idioma, mas principalmente em T-SQL) é difícil. É difícil até mesmo encontrar o início e o fim da lista de parâmetros porque a capacidade de análise do T-SQL é muito limitada e há mais casos extremos do que você pode imaginar. Alguns exemplos:
  • Você não pode confiar na presença de ( e ) para indicar a lista de parâmetros, pois são opcionais (e podem ser encontrados em toda a lista de parâmetros)
  • Não é possível analisar facilmente o primeiro AS para marcar o início do corpo, pois pode aparecer por outros motivos
  • Você não pode confiar na presença de BEGIN para marcar o início do corpo, já que é opcional
  • É difícil dividir por vírgulas, pois elas podem aparecer dentro de comentários, dentro de literais de string e como parte de declarações de tipo de dados (pense em (precision, scale) )
  • É muito difícil analisar os dois tipos de comentários, que podem aparecer em qualquer lugar (incluindo dentro de literais de string) e podem ser aninhados
  • Você pode encontrar inadvertidamente palavras-chave, vírgulas e sinais de igual importantes dentro de literais de string e comentários
  • Você pode ter valores padrão que não sejam números ou literais de string (pense em {fn curdate()} ou GETDATE )

Existem tantas pequenas variações de sintaxe que as técnicas normais de análise de strings se tornam ineficazes. Eu vi AS já? Foi entre um nome de parâmetro e um tipo de dados? Foi depois de um parêntese direito que circunda toda a lista de parâmetros, ou [um?] que não teve uma correspondência antes da última vez que vi um parâmetro? Essa vírgula separa dois parâmetros ou faz parte da precisão e da escala? Quando você está percorrendo uma string uma palavra por vez, ela continua e continua, e há tantos bits que você precisa rastrear.

Veja este exemplo (intencionalmente ridículo, mas ainda sintaticamente válido):
/* 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;

Analisar os valores padrão dessa definição usando T-SQL é difícil. Muito difícil . Sem BEGIN para marcar corretamente o final da lista de parâmetros, toda a confusão de comentários e todos os casos em que palavras-chave como AS pode significar coisas diferentes, você provavelmente terá um conjunto complexo de expressões aninhadas envolvendo mais SUBSTRING e CHARINDEX padrões do que você já viu em um lugar antes. E você provavelmente ainda vai acabar com @d e @e parecendo parâmetros de procedimento em vez de variáveis ​​locais.

Pensando um pouco mais sobre o problema, e procurando para ver se alguém conseguiu algo novo na última década, me deparei com este ótimo post de Michael Swart. Nesse post, Michael usa o TSqlParser do ScriptDom para remover comentários de linha única e de várias linhas de um bloco de T-SQL. Então, escrevi algum código do PowerShell para percorrer um procedimento para ver quais outros tokens foram identificados. Vamos dar um exemplo mais simples sem todos os problemas intencionais:
CREATE PROCEDURE dbo.procedure1
  @param1 int
AS PRINT 1;
GO

Abra o Visual Studio Code (ou seu IDE PowerShell favorito) e salve um novo arquivo chamado Test1.ps1. O único pré-requisito é ter a versão mais recente do Microsoft.SqlServer.TransactSql.ScriptDom.dll (que você pode baixar e extrair do sqlpackage aqui) na mesma pasta do arquivo .ps1. Copie este código, salve e execute ou depure:
# 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)";
        }
    }
}

Os resultados:
=====================================
CreateProcedureStatement
====================================

Criar:CREATE
Espaço em branco:
Procedimento:PROCEDIMENTO
Espaço em branco:
Identificador:dbo
Ponto:.
Identificador:procedimento1
Espaço em branco:
WhiteSpace :
Variável :@param1
WhiteSpace :
As :AS
WhiteSpace :
Identificador :int
WhiteSpace :
As :AS
WhiteSpace :
Print :PRINT
WhiteSpace :
Integer :1
Ponto-e-vírgula :;
WhiteSpace :
Go :GO
FimDeArquivo:

Para eliminar um pouco do ruído, podemos filtrar alguns TokenTypes dentro do último loop for:
      foreach ($token in $statement.ScriptTokenStream)
      {
         if ($token.TokenType -notin "WhiteSpace", "Go", "EndOfFile", "SemiColon")
         {
           Write-Host "  $($token.TokenType.ToString().PadRight(16)) : $($token.Text)";
         }
      }

Terminando com uma série mais concisa de tokens:
=====================================
CreateProcedureStatement
====================================

Criar :CREATE
Procedimento :PROCEDURE
Identificador :dbo
Ponto :.
Identificador :procedure1
Variável :@param1
As :AS
Identificador:int
As:AS
Imprimir:PRINT
Inteiro:1

A maneira como isso é mapeado para um procedimento visualmente:

Cada token analisado a partir deste corpo de procedimento simples.

Você já pode ver os problemas que teremos ao tentar reconstruir nomes de parâmetros, tipos de dados e até mesmo encontrar o final da lista de parâmetros. Depois de pesquisar mais sobre isso, me deparei com um post de Dan Guzman que destacava uma classe ScriptDom chamada TSqlFragmentVisitor, que identifica fragmentos de um bloco de T-SQL analisado. Se mudarmos um pouco a tática, podemos inspecionar fragmentos em vez de tokens . Um fragmento é essencialmente um conjunto de um ou mais tokens e também possui sua própria hierarquia de tipos. Até onde eu sei, não há ScriptFragmentStream para percorrer fragmentos, mas podemos usar um Visitante padrão para fazer essencialmente a mesma coisa. Vamos criar um novo arquivo chamado Test2.ps1, colar este código e executá-lo:
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;
   }
}

Resultados (interessantes para este exercício em negrito ):
TSqlScript
TSqlBatch
CreateProcedureStatement
ProcedureReference
SchemaObjectName
Identificador
Identificador
ProcedureParameter
Identificador
SqlDataTypeReference
SchemaObjectName
Identificador
StatementList
PrintStatement
IntegerLiteral
Se tentarmos mapear isso visualmente para o nosso diagrama anterior, fica um pouco mais complexo. Cada um desses fragmentos é um fluxo de um ou mais tokens e, às vezes, eles se sobrepõem. Vários tokens de instrução e palavras-chave nem são reconhecidos por conta própria como parte de um fragmento, como CREATE , PROCEDURE , AS e GO . O último é compreensível, pois nem é T-SQL, mas o analisador ainda precisa entender que separa lotes.

Comparando a maneira como os tokens de instrução e os tokens de fragmento são reconhecidos.

Para reconstruir qualquer fragmento no código, podemos percorrer seus tokens durante uma visita a esse fragmento. Isso nos permite derivar coisas como o nome do objeto e os fragmentos de parâmetro com análise e condicionais muito menos tediosas, embora ainda tenhamos que fazer um loop dentro do fluxo de token de cada fragmento. Se alterarmos Write-Host $fragment.GetType().Name; no script anterior para isso:
[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;
  }
}

A saída é:
==========================
ProcedureReference
===========================

dbo.procedure1

==========================
ProcedureParameter
==========================

@param1 AS int

Temos o objeto e o nome do esquema juntos sem precisar realizar nenhuma iteração ou concatenação adicional. E temos toda a linha envolvida em qualquer declaração de parâmetro, incluindo o nome do parâmetro, o tipo de dados e qualquer valor padrão que possa existir. Curiosamente, o visitante manipula @param1 int e int como dois fragmentos distintos, essencialmente contando duas vezes o tipo de dados. O primeiro é um ProcedureParameter fragmento, e o último é um SchemaObjectName . Nós realmente só nos importamos com o primeiro SchemaObjectName referência (dbo.procedure1 ) ou, mais especificamente, apenas aquele que segue ProcedureReference . Prometo que vamos lidar com isso, mas não com todos hoje. Se alterarmos o $procedure constante para isso (adicionando um comentário e um valor padrão):
$procedure = @"
CREATE PROCEDURE dbo.procedure1
  @param1 AS int = /* comment */ -64
AS PRINT 1;
GO
"@

Então a saída se torna:
==========================
ProcedureReference
===========================

dbo.procedure1

==========================
ProcedureParameter
==========================

@param1 AS int =/* comentário */ -64

Isso ainda inclui quaisquer tokens na saída que sejam realmente comentários. Dentro do loop for, podemos filtrar qualquer tipo de token que queremos ignorar para resolver isso (também removo AS supérfluo palavras-chave neste exemplo, mas talvez você não queira fazer isso se estiver reconstruindo corpos de módulo):
for ($i = $fragment.FirstTokenIndex; $i -le $fragment.LastTokenIndex; $i++)
{
  $token = $fragment.ScriptTokenStream[$i];
  if ($token.TokenType -notin ("MultiLineComment", "SingleLineComment", "As"))
  {
    $output += $token.Text;
  }
}

A saída é mais limpa, mas ainda não é perfeita.
==========================
ProcedureReference
===========================

dbo.procedure1

==========================
ProcedureParameter
==========================

@param1 int =-64

Se quisermos separar o nome do parâmetro, o tipo de dados e o valor padrão, fica mais complexo. Enquanto estamos percorrendo o fluxo de token para qualquer fragmento, podemos separar o nome do parâmetro de qualquer declaração de tipo de dados apenas rastreando quando atingimos um EqualsSign símbolo. Substituindo o loop for por esta lógica adicional:
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;
}

Agora a saída é:
==========================
ProcedureReference
===========================

dbo.procedure1

==========================
ProcedureParameter
==========================

Nome do parâmetro:@param1
Tipo de parâmetro:int
Padrão:-64

Isso é melhor, mas ainda há mais para resolver. Existem palavras-chave de parâmetro que ignorei até agora, como OUTPUT e READONLY , e precisamos de lógica quando nossa entrada é um lote com mais de um procedimento. Tratarei dessas questões na parte 2.

Enquanto isso, experimente! Há muitas outras coisas poderosas que você pode fazer com ScriptDOM, TSqlParser e TSqlFragmentVisitor.

[ Parte 1 | Parte 2 | Parte 3]