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?
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
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 ...
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
contreINFORMATION_SCHEMA
est bon - il est plus sûr de l'ajouter - mais@Condition
est jamais va paramétrer danssp_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!