web-dev-qa-db-fra.com

Paramétrer le nom de la table en SQL dynamique

J'ai travaillé sur quelques procédures stockées qui ont des paramètres conditionnels, mais l'une d'entre elles me pose un problème que je n'arrive pas à comprendre. Voici le code de la procédure:

CREATE PROCEDURE dbo.GetTableData(
    @TblName   VARCHAR(50),
    @Condition VARCHAR(MAX) = NULL,
) AS
BEGIN
    IF(EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @TblName))
        BEGIN
            DECLARE @SQL NVARCHAR(MAX) = N'
            SELECT * FROM @TblName WHERE 1=1'
            + CASE WHERE @Condition IS NOT NULL THEN
            ' AND ' + @Condition ELSE N'' END

            DECLARE @params NVARCHAR(MAX) = N'
                @TblName   VARCHAR(50),
                @Condition VARCHAR(MAX)';

            PRINT @SQL

            EXEC sys.sp_executesql @SQL, @params,
                @TblName,
                @Condition
        END
    ELSE
        RETURN 1
END

La façon dont je voudrais que la procédure fonctionne, c'est qu'elle est censée me permettre de faire des recherches rapides sur la table. Donc, si je veux tout voir de ma table Parts, je courrais

EXEC GetTableData 'parts'

Ou si je voulais tout voir dans le tableau des pièces avec un fournisseur spécifique, je courrais

EXEC GetTableData 'parts', 'supplier LIKE ''A2A Systems'''

Maintenant, dans l'exemple ci-dessus, lorsque je l'exécute, le PRINT @SQL line affiche la requête comme suit:

SELECT * FROM @TblName WHERE 1 = 1 AND supplier LIKE 'A2A Systems'

Donc, la requête est bien mise en place (il semble).

Cependant, après l'impression, j'obtiens l'erreur suivante:

Msg 1087, niveau 16, état 1, ligne 4

Doit déclarer la variable de table "@TblName"

J'obtiens toujours cette erreur si je change la ligne EXEC en:

EXEC GetTableData @TblName='parts', @Condition='supplier LIKE ''A2A Systems'''

Alors qu'est-ce que je fais mal ici? Pourquoi ne prend-il pas mon @TblName valeur variable?

2
Skitzafreak

Vous devez modifier votre procédure de cette façon:

CREATE PROCEDURE dbo.GetTableData(
@TblName   VARCHAR(50),
@Condition VARCHAR(MAX) = NULL
) AS
BEGIN
    IF(EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @TblName))
        BEGIN
            DECLARE @SQL NVARCHAR(MAX) = N'
            SELECT * FROM ' + @TblName + 'WHERE 1=1'
        + CASE WHEN @Condition IS NOT NULL THEN
        ' AND ' + @Condition ELSE N'' END

        DECLARE @params NVARCHAR(MAX) = N'
            @TblName   VARCHAR(50),
            @Condition VARCHAR(MAX)';

        PRINT @SQL

        EXEC sys.sp_executesql @SQL, @params,
            @TblName,
            @Condition
    END
ELSE
    RETURN 1
END

Votre variable @TblName ne doit pas être à l'intérieur de la chaîne @SQL

2

Vous ne pouvez pas paramétrer les noms d'entités (tables, colonnes, vues, etc.). Vous devez le faire de manière plus risquée:

        DECLARE @SQL NVARCHAR(MAX) = N'
        SELECT * FROM ' + QUOTENAME(@TblName) + N' WHERE 1=1'
        + CASE WHERE @Condition IS NOT NULL THEN
        ' AND ' + @Condition ELSE N'' END

        DECLARE @params NVARCHAR(MAX) = N'
            @Condition VARCHAR(MAX)';

        PRINT @SQL

        EXEC sys.sp_executesql @SQL, @params,
            @Condition

QUOTENAME() est généralement suffisant pour se protéger contre une exécution dangereuse (conduisant, potentiellement, à une injection SQL), mais pour le rendre un peu plus sûr, vous devriez envisager (a) de préfixer le nom de la table avec le préfixe de schéma correct (par exemple ...FROM dbo.' + QUOTENAME(@TblName) + ... et (b) vérifiant d'abord l'existence:

IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = @TblName)
BEGIN
  RAISERROR(N'Nice try, robot.', 11, 1);
  RETURN;
END

DECLARE @SQL ...
7
Aaron Bertrand

Je vais ajouter à cela, pour couvrir mes commentaires au poseur de questions et aux répondeurs suivants.

Comme je le dis dans le commentaire ci-dessus:

C'est l'un de ces anti-modèles que les codeurs qui connaissent les langages de programmation "traditionnels" qui font le saut vers SQL doivent les avoir vaincus par expérience.

SQL n'est pas comme les langages traditionnels et vous devez arrêter de le penser tel qu'il est.

Ce que vous essayez de faire a été essayé par beaucoup (y compris moi) et nous avons tous souffert des résultats.

Astuce: Vérification @TblName contre INFORMATION_SCHEMA est bon - il est plus sûr de l'ajouter - mais @Condition est jamais va paramétrer dans sp_executesql, laissant un trou flagrant eval dans la sécurité de votre application.

Un commentaire est un espace trop petit ici, pour expliquer pleinement pourquoi je prends la position ci-dessus.

Dans le cas de la conception de votre procédure, vous prenez deux variables, @TblName et @Conditions, et essayez de les replier en SQL dynamique. Comme vous l'avez découvert, seules certaines parties de la syntaxe SQL acceptent des variables. Cela peut être grossièrement divisé en endroits de la syntaxe où valeurs sont attendues (celles-ci peuvent généralement être fournies avec des variables) et en endroits où la structure syntaxique est attendue ( ne peut pas être remplacé par des variables).

Je concède que parfois ce serait Sympa si certaines structures syntaxiques compris les variables mais, dans le cas d'un a SELECT * FROM ..., l'instruction ... est syntaxique, pas une valeur. En tant que tel, il doit être composé directement dans l'instruction SQL et non fourni par une variable.

Vous pouvez créer un générique, inconditionnel SELECT * FROM ... procédure utilisant du SQL dynamique, mais les règles pour générer du SQL dynamique sont que vous devez soigneusement valider, nettoyer et échapper de manière appropriée tous les paramètres saisis par l'utilisateur avant qu'ils ne soient ajoutés à une chaîne SQL composée, car il est trop facile pour un utilisateur final malveillant de fournir des chaînes qui termineraient votre commande SQL composée et démarrer une autre commande que l'utilisateur final malveillant contrôle. Le sp_executesql ne serait pas en mesure de faire la différence entre votre commande et leur commande, et s'exécuterait les deux dans un seul lot.

Le nom de la table cible est l'un de ces paramètres saisis par l'utilisateur, mais il est facile à valider, à nettoyer et à échapper. Vous effectuez déjà la partie de validation de ceci avec votre vérification contre INFORMATION_SCHEMA.TABLES. Les parties de nettoyage/d'échappement ont été bien couvertes dans les autres réponses (par exemple QUOTENAME).

Cependant, en plus du nom de la table, la conception de votre procédure vous permet également de fournir des conditions qui limitent les résultats de la table cible. Il est clair d'après votre composition SQL que vous passerez des clauses de type SQL, telles que foo = 1 AND bar = 'hello world', et que vous les utilisez dans une clause WHERE dans votre chaîne SQL composée.

Malheureusement, le nom de la colonne et l'opérateur et la conjonction de vos clauses (qu'elles soient AND ou OR) sont également des éléments de structure syntaxique qui ne peuvent pas être passés par variable. Cela signifie que vous devez également les ajouter à votre chaîne SQL composée, plutôt en utilisant un nom de variable.

Cependant, valider, nettoyer et échapper est un travail beaucoup ( beaucoup difficile pour les clauses WHERE saisies par l'utilisateur, donc vous vous ouvrez à un risque d'attaque par injection beaucoup plus élevé que pour un simple paramètre de nom de table.

Il me semble que l'intention de la procédure dans la question est d'éviter d'écrire du SQL. Si vous voulez aller de l'avant et le faire - que ce soit parce que vous manquez de confiance en SQL ou parce que vous pensez que cela facilitera l'écriture de votre couche d'application, vous devriez envisager d'implémenter tout cadre ORM bien pris en charge pour le langage vous voulez écrire.

Si ce n'est pas votre intention d'éviter d'écrire SQL, alors vous devriez réellement écrire du SQL et ne pas essayer de le contourner avec un anti-pattern.

Avec cette diatribe terminée, je vais montrer à quel point il est difficile d'écrire une procédure générique SELECT générique correctement validée avec des clauses arbitraires. Notez que je catégoriquement pas vous recommande d'utiliser ce code - je ne l'ai écrit que parce que c'est vendredi. Je garantis que je n'ai pas couvert tous les cas Edge possibles. Si vous l'utilisiez, un jour, il essaierait probablement de vous tuer dans votre sommeil.

Quoi qu'il en soit, voici:

IF OBJECT_ID('dbo.get_any', 'P') IS NOT NULL DROP PROCEDURE dbo.get_any;
GO
IF TYPE_ID('dbo.GenericCondition') IS NOT NULL DROP TYPE dbo.GenericCondition;
GO

CREATE TYPE dbo.GenericCondition AS TABLE (
     ordinal        INTEGER         IDENTITY(1, 1)
    ,conjunction    VARCHAR(3)      NULL
    ,colname        SYSNAME         NOT NULL
    ,operator       VARCHAR(2)      NOT NULL
    ,value          SQL_VARIANT     NULL
)
GO

CREATE PROCEDURE dbo.get_any (
     @tablename     NVARCHAR(515)
    ,@conditions    dbo.GenericCondition READONLY
)
WITH EXECUTE AS CALLER
AS
    DECLARE @server SYSNAME
           ,@dbname SYSNAME
           ,@schema SYSNAME
           ,@object SYSNAME;

    -- extract component names from the passed table indicator
    SELECT @server = PARSENAME(@tablename, 4)
          ,@dbname = COALESCE(PARSENAME(@tablename, 3), DB_NAME())
          ,@schema = COALESCE(PARSENAME(@tablename, 2), N'dbo')
          ,@object = PARSENAME(@tablename, 1);

    -- check that the server and database exists
    IF (@server IS NULL OR EXISTS (SELECT 1 FROM sys.servers WHERE name = @server))
       AND EXISTS (SELECT 1 FROM sys.databases WHERE name = @dbname)
    BEGIN
        DECLARE @sql NVARCHAR(MAX);
        DECLARE @params NVARCHAR(MAX);
        DECLARE @target NVARCHAR(2000);
        DECLARE @cols TABLE (cname SYSNAME, tname SYSNAME, tsize NVARCHAR(32));

        -- escape the server and database name for use in dynamic queries
        SET @target = CASE WHEN @server IS NOT NULL
                           THEN N'[' + REPLACE(@server, N']', N']]') + N'].'
                           ELSE N''
                           END
                    + N'[' + REPLACE(@dbname, N']', N']]') + N']'

        -- get column information from the target database's system tables
        SET @sql = N'
            SELECT
                 c.name
                ,t.name
                ,CASE WHEN t.name IN (''char'', ''nchar'', ''binary'', ''varchar'', ''nvarchar'', ''varbinary'')
                      THEN N''('' + COALESCE(CONVERT(NVARCHAR(32), NULLIF(c.max_length, -1)), N''max'') + N'')''
                      WHEN c.max_length = t.max_length
                       AND c.precision = t.precision
                       AND c.scale = t.scale
                      THEN N''''
                      ELSE N''('' + CONVERT(NVARCHAR(32), c.precision) + N'','' + CONVERT(NVARCHAR(32), c.scale) + N'')''
                      END
            FROM ' + @target + N'.sys.objects o
            INNER JOIN ' + @target + N'.sys.schemas s ON s.schema_id = o.schema_id
            INNER JOIN ' + @target + N'.sys.columns c ON c.object_id = o.object_id
            INNER JOIN ' + @target + N'.sys.types t ON c.user_type_id = t.user_type_id
            WHERE s.name = @schema
              AND o.name = @object
              AND o.type_desc IN (''SYSTEM_TABLE'', ''USER_TABLE'', ''VIEW'');
        ';
        SET @params = N'@schema SYSNAME, @object SYSNAME';

        /* debug */-- PRINT ('/* getting types */' + @sql);
        INSERT INTO @cols(cname, tname, tsize)
        EXEC sp_executesql @command = @sql
                          ,@params  = @params
                          ,@schema  = @schema
                          ,@object  = @object;

        /* debug */-- SELECT * FROM @cols;

        -- if we have no columns, then the schema or table does not exist
        IF EXISTS(SELECT 1 FROM @cols) BEGIN
            SET @target = @target
                        + N'.[' + REPLACE(@schema, N']', N']]') + N'].['
                        + REPLACE(@object, N']', N']]') + N']';

            /* debug */-- RAISERROR('/* target = %s /*', 10, 1, @target) WITH NOWAIT;

            -- now we check the columns supplied in any conditions, to make sure they exist
            DECLARE @badlist NVARCHAR(MAX);

            SELECT @badlist = STUFF((SELECT N', "' + colname + N'"'
                                     FROM @conditions
                                     WHERE colname NOT IN (SELECT cname FROM @cols)
                                     FOR XML PATH(N''), TYPE).value(N'.', 'NVARCHAR(MAX)'),
                                     1, 2, N'');

            /* debug */-- RAISERROR('/* badcols = %s /*', 10, 1, @badlist) WITH NOWAIT;
            IF @badlist IS NOT NULL
                RAISERROR('Cannot find column(s) %s in object %s.%s in database "%s" on server "%s"',
                          16, 1, @badlist, @schema, @object, @dbname, @server)
                          WITH NOWAIT;
            ELSE BEGIN
                -- we check the operators in the conditionals now, for valid syntax we support
                SELECT @badlist = STUFF((SELECT N', "' + operator + N'"'
                                         FROM @conditions
                                         WHERE operator NOT IN ('=', '<', '<=', '>', '>=', '<>')
                                         FOR XML PATH(N''), TYPE).value(N'.', 'NVARCHAR(MAX)'),
                                         1, 2, N'');

                /* debug */-- RAISERROR('/* badops = %s /*', 10, 1, @badlist) WITH NOWAIT;
                IF @badlist IS NOT NULL
                    RAISERROR('Invalid operator(s) %s in conditions', 16, 1, @badlist)
                              WITH NOWAIT;
                ELSE BEGIN
                    -- we check the conjunctions, for valid syntax we support
                    SELECT @badlist = STUFF((SELECT N', "' + conjunction + N'"'
                                             FROM @conditions
                                             WHERE (ordinal = 1 AND conjunction IS NOT NULL)
                                                OR (ordinal > 1 AND conjunction NOT IN ('AND', 'OR'))
                                             FOR XML PATH(N''), TYPE).value(N'.', 'NVARCHAR(MAX)'),
                                             1, 2, N'');

                    /* debug */-- RAISERROR('/* badconjs = %s /*', 10, 1, @badlist) WITH NOWAIT;
                    IF @badlist IS NOT NULL
                        RAISERROR('Invalid conjunction(s) %s in conditions', 16, 1, @badlist)
                                  WITH NOWAIT;
                    ELSE BEGIN
                        -- we have done the validations, and can now build our SQL, where
                        -- we use our properly-escaped target and fold in the conditions,
                        -- which we also escape heavily, using a horrid binary/Base64
                        -- conversion below, to cover arbitrary comaprison of as many of
                        -- the standard types as possible...
                        WITH b64 AS (
                            SELECT *,
                                b64 = (SELECT CONVERT(VARBINARY(MAX), value)
                                       FOR XML PATH(''), TYPE, BINARY BASE64)
                                       .value('.', 'VARCHAR(MAX)')
                            FROM @conditions
                        )
                        SELECT @sql = N'SELECT * FROM ' + @target
                                    + COALESCE(
                                      (SELECT NCHAR(13) + NCHAR(10)
                                            + CASE ordinal WHEN 1 THEN N'WHERE' ELSE conjunction END
                                            + N' '
                                            + CASE WHEN x.value IS NULL AND x.operator = '='
                                                   THEN N'[' + REPLACE(colname, N']', N']]') + N'] IS NULL'
                                                   WHEN x.value IS NULL AND x.operator = '<>'
                                                   THEN N'[' + REPLACE(colname, N']', N']]') + N'] IS NOT NULL'
                                                   ELSE N'[' + REPLACE(colname, N']', N']]') + N'] '
                                                      + operator
                                                      + N' CONVERT([' + REPLACE(c.tname, N']', N']]') + N']' + c.tsize
                                                      + N', CONVERT(XML, ''' + b64 + ''').value(''xs:base64Binary(.)'', ''VARBINARY(MAX)''))'
                                                   END
                                        FROM b64 x
                                        INNER JOIN @cols c ON x.colname = c.cname
                                        ORDER BY ordinal
                                        FOR XML PATH(N''), TYPE, BINARY BASE64).value(N'.', N'NVARCHAR(MAX)'),
                                        N'');

                        /* debug */-- PRINT ('/* actual sql */ ' + @sql);

                        EXEC sp_executesql @command = @sql;

                        /* debug */-- RAISERROR('done...', 10, 1) WITH NOWAIT;
                    END
                END
            END
        END
        ELSE RAISERROR(N'Cannot find object "%s.%s" in database "%s" on server "%s"',
                       16, 1, @schema, @object, @dbname, @server)
                       WITH NOWAIT;
    END
    ELSE RAISERROR(N'Cannot find one of server "%s" or database "%s"',
                   16, 1, @server, @dbname)
                   WITH NOWAIT;
GO

Si vous ne vouliez aucune condition, vous l'appeleriez tout simplement:

EXEC dbo.get_any @tablename = 'dbo.my_target_table'

... et cela générerait et exécuterait la simple instruction:

SELECT * FROM [dbo].[my_target_table]

Si vous vouliez des conditions, vous l'appeleriez en utilisant la convention quelque peu horrible suivante:

DECLARE @c AS GenericCondition;
INSERT INTO @c (conjunction, colname, operator, value)
VALUES (NULL,  'foo', '=', CONVERT(SQL_VARIANT, 1))
      ,('AND', 'bar', '>', 4)
      ,('AND', 'baz', '<', CONVERT(DATETIME, '2008-03-19T00:00:00'))
      ,('OR',  'qux', '<>', 'arrrrghhh!');

EXEC dbo.get_any @tablename = 'dbo.my_target_table'
                ,@conditions = @c;

... et il générerait et exécuterait dynamiquement:

SELECT * FROM [dbo].[my_target_table]
WHERE [foo] = CONVERT([int], CONVERT(XML, 'AAAAAQ==').value('xs:base64Binary(.)', 'VARBINARY(MAX)'))
AND [bar] > CONVERT([int], CONVERT(XML, 'AAAABA==').value('xs:base64Binary(.)', 'VARBINARY(MAX)'))
AND [baz] < CONVERT([datetime], CONVERT(XML, 'AACaZAAAAAA=').value('xs:base64Binary(.)', 'VARBINARY(MAX)'))
OR [qux] <> CONVERT([varchar](100), CONVERT(XML, 'YXJycnJnaGhoIQ==').value('xs:base64Binary(.)', 'VARBINARY(MAX)'))

Ce CONVERT- chargé de trucs base64/binaires est une surpuissance massive, conçu pour permettre de fournir autant de types de données différents que possible. Vous pourriez probablement vous en sortir en utilisant des transtypages implicites de types de chaînes pour 70% de vos cas d'utilisation. Je m'attends à ce que même les horribles trucs base64/binaires échouent dur pour au moins 10% des cas d'utilisation.

Mais, en tout cas, franchement, je sais que je préfère écrire et exécuter:

SELECT * 
FROM dbo.my_target_table
WHERE foo = 1
  AND bar > 4
  AND baz < '2008-03-19T00:00:00'
  AND qux <> 'arrrrghhh!'

... que l'abomination que j'ai mise en place ci-dessus. J'aurais aussi plus de contrôle sur les clauses WHERE complexes.

Maintenant, allez blanchir vos yeux et ne pensez plus jamais à l'approche ci-dessus!

3
jimbobmcgee