sql >> Base de Datos >  >> RDS >> Database

Analizar los valores predeterminados de los parámetros usando PowerShell – Parte 1

[ Parte 1 | Parte 2 | Parte 3 ]

Si alguna vez ha tratado de determinar los valores predeterminados para los parámetros de procedimientos almacenados, probablemente tenga marcas en la frente por golpearlo en su escritorio repetida y violentamente. La mayoría de los artículos que hablan sobre la recuperación de información de parámetros (como este consejo) ni siquiera mencionan la palabra predeterminada. Esto se debe a que, a excepción del texto sin procesar almacenado en la definición del objeto, la información no se encuentra en ninguna parte de las vistas del catálogo. Hay columnas has_default_value y default_value en sys.parameters esa mirada prometedores, pero solo se completan para módulos CLR.

La obtención de valores predeterminados mediante T-SQL es engorrosa y propensa a errores. Hace poco respondí una pregunta en Stack Overflow sobre este problema, y ​​me llevó por el camino de la memoria. En 2006, me quejé a través de varios elementos de Connect sobre la falta de visibilidad de los valores predeterminados de los parámetros en las vistas del catálogo. Sin embargo, el problema aún existe en SQL Server 2019. (Este es el único elemento que encontré que llegó al nuevo sistema de comentarios).

Si bien es un inconveniente que los valores predeterminados no estén expuestos en los metadatos, lo más probable es que no estén allí porque es difícil analizarlos fuera del texto del objeto (en cualquier idioma, pero particularmente en T-SQL). Incluso es difícil encontrar el inicio y el final de la lista de parámetros porque la capacidad de análisis de T-SQL es muy limitada y hay más casos extremos de los que puede imaginar. Algunos ejemplos:

  • No puede confiar en la presencia de ( y ) para indicar la lista de parámetros, ya que son opcionales (y se pueden encontrar a lo largo de la lista de parámetros)
  • No puede analizar fácilmente el primer AS para marcar el inicio del cuerpo, ya que puede aparecer por otros motivos
  • No puedes confiar en la presencia de BEGIN para marcar el inicio del cuerpo, ya que es opcional
  • Es difícil dividir con comas, ya que pueden aparecer dentro de comentarios, dentro de cadenas literales y como parte de declaraciones de tipos de datos (piense en (precision, scale) )
  • Es muy difícil analizar ambos tipos de comentarios, que pueden aparecer en cualquier lugar (incluidos los literales de cadena internos) y se pueden anidar
  • Puedes encontrar sin darte cuenta palabras clave, comas y signos de igual importantes dentro de cadenas literales y comentarios.
  • Puede tener valores predeterminados que no sean números o cadenas literales (piense en {fn curdate()} o GETDATE )

Hay tantas pequeñas variaciones de sintaxis que las técnicas normales de análisis de cadenas se vuelven ineficaces. ¿He visto AS ¿ya? ¿Fue entre un nombre de parámetro y un tipo de datos? ¿Fue después de un paréntesis derecho que rodea toda la lista de parámetros, o [¿uno?] que no tenía una coincidencia antes de la última vez que vi un parámetro? ¿Es esa coma que separa dos parámetros o es parte de la precisión y la escala? Cuando recorre una cadena palabra por palabra, sigue y sigue, y hay tantos bits que necesita rastrear.

Tome este ejemplo (intencionalmente ridículo, pero sintácticamente 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;

Analizar los valores predeterminados de esa definición usando T-SQL es difícil. Muy difícil . Sin BEGIN para marcar correctamente el final de la lista de parámetros, todo el lío de comentarios y todos los casos en los que palabras clave como AS puede significar cosas diferentes, probablemente tendrá un conjunto complejo de expresiones anidadas que involucran más SUBSTRING y CHARINDEX patrones que jamás hayas visto en un lugar antes. Y probablemente terminarás con @d y @e luciendo como parámetros de procedimiento en lugar de variables locales.

Pensando un poco más en el problema y buscando si alguien había logrado algo nuevo en la última década, me encontré con esta gran publicación de Michael Swart. En esa publicación, Michael usa el TSqlParser de ScriptDom para eliminar comentarios de una sola línea y de varias líneas de un bloque de T-SQL. Así que escribí un código de PowerShell para seguir un procedimiento para ver qué otros tokens se identificaron. Tomemos un ejemplo más simple sin todos los problemas intencionales:

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

Abra Visual Studio Code (o su IDE de PowerShell favorito) y guarde un nuevo archivo llamado Test1.ps1. El único requisito previo es tener la última versión de Microsoft.SqlServer.TransactSql.ScriptDom.dll (que puede descargar y extraer de sqlpackage aquí) en la misma carpeta que el archivo .ps1. Copie este código, guárdelo y luego ejecute o 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)";
        }
    }
}

Los resultados:

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

Crear :CREAR
Espacio en blanco :
Procedimiento :PROCEDIMIENTO
Espacio en blanco :
Identificador :dbo
Punto :.
Identificador :procedimiento1
Espacio en blanco :
WhiteSpace :
Variable :@param1
WhiteSpace :
As :AS
WhiteSpace :
Identificador :int
WhiteSpace :
As :AS
Espacio en blanco :
Imprimir :PRINT
Espacio en blanco :
Entero :1
Punto y coma :;
Espacio en blanco :
Ir :IR
FinDeArchivo:

Para eliminar parte del ruido, podemos filtrar algunos TokenTypes dentro del último ciclo for:

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

Terminando con una serie más concisa de tokens:

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

Crear :CREAR
Procedimiento :PROCEDIMIENTO
Identificador :dbo
Punto :.
Identificador :procedimiento1
Variable :@param1
Como :AS
Identificador :int
As :AS
Imprimir :PRINT
Entero :1

La forma en que esto se asigna a un procedimiento visualmente:

Cada token analizado a partir de este cuerpo de procedimiento simple.

Ya puede ver los problemas que tendremos al tratar de reconstruir nombres de parámetros, tipos de datos e incluso encontrar el final de la lista de parámetros. Después de investigar esto un poco más, encontré una publicación de Dan Guzman que destacaba una clase de ScriptDom llamada TSqlFragmentVisitor, que identifica fragmentos de un bloque de T-SQL analizado. Si cambiamos un poco la táctica, podemos inspeccionar fragmentos en lugar de fichas . Un fragmento es esencialmente un conjunto de uno o más tokens y también tiene su propia jerarquía de tipos. Hasta donde yo sé, no hay ScriptFragmentStream para iterar a través de fragmentos, pero podemos usar un Visitor patrón para hacer esencialmente lo mismo. Creemos un nuevo archivo llamado Test2.ps1, peguemos este código y ejecútelo:

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 (interesantes para este ejercicio en negrita ):

TSqlScript
TSqlBatch
CreateProcedureStatement
ProcedureReference
SchemaObjectName
Identificador
Identificador
Parámetro de procedimiento
Identificador
SqlDataTypeReference
SchemaObjectName
Identificador
StatementList
PrintStatement
IntegerLiteral

Si tratamos de mapear esto visualmente a nuestro diagrama anterior, se vuelve un poco más complejo. Cada uno de estos fragmentos es en sí mismo un flujo de uno o más tokens y, a veces, se superponen. Varios tokens de declaración y palabras clave ni siquiera se reconocen por sí solos como parte de un fragmento, como CREATE , PROCEDURE , AS y GO . Esto último es comprensible ya que ni siquiera es T-SQL en absoluto, pero el analizador aún tiene que entender que separa lotes.

Comparación de la forma en que se reconocen los tokens de declaración y los tokens de fragmento.

Para reconstruir cualquier fragmento en el código, podemos iterar a través de sus tokens durante una visita a ese fragmento. Esto nos permite derivar cosas como el nombre del objeto y los fragmentos de parámetros con un análisis y condicionales mucho menos tediosos, aunque todavía tenemos que recorrer el flujo de token de cada fragmento. Si cambiamos Write-Host $fragment.GetType().Name; en el script anterior a esto:

[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 salida es:

==========================
Referencia del procedimiento
==========================

dbo.procedimiento1

==========================
Parámetro de procedimiento
==========================

@param1 AS int

Tenemos el objeto y el nombre del esquema juntos sin tener que realizar ninguna iteración o concatenación adicional. Y tenemos toda la línea involucrada en cualquier declaración de parámetros, incluido el nombre del parámetro, el tipo de datos y cualquier valor predeterminado que pueda existir. Curiosamente, el visitante maneja @param1 int y int como dos fragmentos distintos, esencialmente contando dos veces el tipo de datos. El primero es un ProcedureParameter fragmento, y el último es un SchemaObjectName . Realmente solo nos importa el primero SchemaObjectName referencia (dbo.procedure1 ) o, más específicamente, solo el que sigue a ProcedureReference . Prometo que nos ocuparemos de eso, pero no de todos hoy. Si cambiamos el $procedure constante a esto (agregando un comentario y un valor predeterminado):

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

Entonces la salida se convierte en:

==========================
Referencia del procedimiento
==========================

dbo.procedimiento1

==========================
Parámetro de procedimiento
==========================

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

Esto todavía incluye todos los tokens en la salida que en realidad son comentarios. Dentro del ciclo for, podemos filtrar cualquier tipo de token que queramos ignorar para abordar esto (también elimino los AS superfluos palabras clave en este ejemplo, pero es posible que no desee hacerlo si está reconstruyendo cuerpos de módulos):

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

La salida es más limpia, pero aún no es perfecta.

==========================
Referencia del procedimiento
==========================

dbo.procedimiento1

==========================
Parámetro de procedimiento
==========================

@param1 int =-64

Si queremos separar el nombre del parámetro, el tipo de datos y el valor predeterminado, se vuelve más complejo. Mientras recorremos el flujo de token para cualquier fragmento dado, podemos separar el nombre del parámetro de cualquier declaración de tipo de datos simplemente rastreando cuando presionamos un EqualsSign simbólico. Reemplazando el bucle for con 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;
}

Ahora la salida es:

==========================
Referencia del procedimiento
==========================

dbo.procedimiento1

==========================
Parámetro de procedimiento
==========================

Nombre del parámetro:@param1
Tipo de parámetro:int
Valor predeterminado:-64

Eso es mejor, pero aún hay más por resolver. Hay palabras clave de parámetros que he ignorado hasta ahora, como OUTPUT y READONLY , y necesitamos lógica cuando nuestra entrada es un lote con más de un procedimiento. Me ocuparé de esos problemas en la parte 2.

Mientras tanto, ¡experimenta! Hay muchas otras cosas poderosas que puede hacer con ScriptDOM, TSqlParser y TSqlFragmentVisitor.

[ Parte 1 | Parte 2 | Parte 3 ]