13

I'm trying to dynamically create triggers, but ran into a confusing issue around using sp_executesql and passing parameters into the dynamic SQL. The following simple test case works:

DECLARE @tableName sysname = 'MyTable';
DECLARE @sql nvarchar(max) = N'
    CREATE TRIGGER TR_' + @tableName + N' ON ' + @tableName + N' FOR INSERT
        AS
        BEGIN
            PRINT 1
        END';
EXEC sp_executesql @sql

However, I want to be able to use @tableName (and other values) as variables within the script, so I passed it along to the sp_executesql call:

DECLARE @tableName sysname = 'ContentItems';
DECLARE @sql nvarchar(max) = N'
    CREATE TRIGGER TR_' + @tableName + N' ON ' + @tableName + N' FOR INSERT
        AS
        BEGIN
            PRINT @tableName
        END';
EXEC sp_executesql @sql, N'@tableName sysname', @tableName=@tableName

When running the above, I get an error:

Msg 156, Level 15, State 1, Line 2
Incorrect syntax near the keyword 'TRIGGER'.

After trying I few things, I've discovered that even if I don't use @tableName in the dynamic SQL at all, I still get this error. And I also get this error trying to create a PROCEDURE (except, obviously, the message is Incorrect syntax near the keyword 'PROCEDURE'.)

Since the SQL runs fine either directly or when not supplying parameters to sp_executesql, this seems like I'm running into a true limitation in the SQL engine, but I don't see it documented anywhere. Does anyone know if there is a way to accept to a dynamic CREATE script, or at least have insight into the underlying limitation that's being run into?

Update I can add a PRINT statement, and get the below SQL, which is valid, and runs successfully (when run directly). I still get the error if there's nothing dynamic in the SQL (it's just a single string with no concatenation).

CREATE TRIGGER TR_ContentItems ON ContentItems FOR INSERT
    AS
    BEGIN
        PRINT @tableName
    END

I also get the same error whether using sysname or nvarchar(max) for the parameter.

bdukes
  • 137,241
  • 21
  • 139
  • 173
  • 4
    Use the print statement and understand the statement generated. PRINT @sql before executing the sp_executesql – Dheerendra Jan 30 '19 at 18:02
  • Also, you need to cast the @tablename to nvarchar – Dheerendra Jan 30 '19 at 18:05
  • May be the Trigger name is already exist. So try a DROP TRIGGER, before create the trigger – Arulkumar Jan 30 '19 at 18:06
  • @Dheerendra Technically a `sysname` data type is an `nvarchar`. – Shawn Jan 30 '19 at 18:09
  • What version of SQL? – Shawn Jan 30 '19 at 18:10
  • I'm running into this on SQL Server 2017 (Developer SKU) – bdukes Jan 30 '19 at 18:34
  • You cannot parameterize object names.That includes the name of the trigger and the name of the table. – SMor Jan 30 '19 at 18:46
  • @SMor I'm not trying to parameterize the object names (that's the reason I need the SQL to be dynamic in the first place), but I want to use the table name later on in the trigger – bdukes Jan 30 '19 at 19:29
  • @bdukes, added an answer with small adjustment to your initial code which will make it working – Alexander Volok Feb 03 '19 at 09:43
  • 1
    _I want to be able to use @tableName (and other values) as variables within the script_... within the script or within the trigger? The correct answer depends on this. – Salman A Feb 03 '19 at 10:52
  • @bdukes You cannot parametrize identifiers(here table name). Same would happen if you try to call prepared statement from Java/C#. The only way is string concatenation/interpolation and using QUOTENAME to avoid SQL Injection attack: [Parameterise table name in .NET/SQL?](https://stackoverflow.com/questions/372033/parameterise-table-name-in-net-sql) – Lukasz Szozda Feb 03 '19 at 20:18

7 Answers7

4

If you execute your create trigger statement that you said you printed... you will find that it does not work. The print statement in the body of the trigger is trying to output @tablename, but is never defined, so you will get an error:

Must declare the scalar variable "@tableName".

But that is not your main issue. As for why you can't seem to execute a DDL statement with execute_sql with parameters, I couldn't find any documentation to explain why... but your experience and others proves that it's troublesome. I believe this post has a pretty good theory: sp_executesql adds statements to executed dynamic script?

You can however execute dynamic sql with DDL statements using the EXECUTE statement. So what you could do is create a parameterized sp_executesql statement that validates your table name and then creates a dynamic sql string to execute with the EXECUTE statement.

It doesn't look pretty, but it works:

DECLARE @tableName sysname = 'MyTable';
DECLARE @sql nvarchar(max) = 
N'
set @tableName = (SELECT name FROM sys.tables WHERE OBJECT_ID = OBJECT_ID(@tableName)) --validate table
DECLARE @CreateTriggerSQL as varchar(max) =
''
CREATE TRIGGER '' + QUOTENAME(''TR_'' + @tableName) + '' ON '' + QUOTENAME( @tableName) + '' FOR INSERT
AS
BEGIN
    PRINT '''''' + @tableName + ''''''
END
''
print isnull(@CreateTriggerSQL, ''INVALID TABLE'')
exec (@CreateTriggerSQL)
';

EXEC sp_executesql @sql, N'@tableName sysname', @tableName=@tableName;

You could also convert this into a stored procedure with parameters instead of running sp_executesql if that were more convenient. It looks a bit cleaner:

CREATE PROCEDURE sp_AddTriggerToTable (@TableName AS sysname) AS

set @tableName = (SELECT name FROM sys.tables WHERE OBJECT_ID = OBJECT_ID(@tableName)) --validate table
DECLARE @CreateTriggerSQL as varchar(max) =
'
CREATE TRIGGER ' + QUOTENAME('TR_' + @tableName) + ' ON ' + QUOTENAME( @tableName) + ' FOR INSERT
AS
BEGIN
    PRINT ''' + @tableName + '''
END
'
print isnull(@CreateTriggerSQL, 'INVALID TABLE')
exec (@CreateTriggerSQL)
GO
Brian Pressler
  • 6,511
  • 1
  • 17
  • 39
2

I would strongly caution against using Dynamic SQL with table names. You are setting yourself up for some serious SQL Injection issues. You should validate anything that goes into the @tableName variable.

That said, in your example...

DECLARE @tableName sysname = 'ContentItems';
DECLARE @sql nvarchar(max) = N'
    CREATE TRIGGER TR_' + @tableName + N' ON ' + @tableName + N' FOR INSERT
        AS
        BEGIN
            PRINT @tableName
        END';
EXEC sp_executesql @sql, N'@tableName sysname', @tableName=@tableName

... you are trying to input your declared @tableName into the text you're creating for @sql, and then you're trying to pass a parameter through spexecutesql. This makes your @sql invalid when trying to call it.

You can try:

DECLARE @tableName sysname = 'ContentItems';
DECLARE @sql nvarchar(max) = N'
    CREATE TRIGGER TR_'' + @tableName + N'' ON '' + @tableName + N'' FOR INSERT
        AS
        BEGIN
            PRINT @tableName
        END';
EXEC sp_executesql @sql, N'@tableName sysname', @tableName=@tableName

... which will give you the string ...

'
CREATE TRIGGER TR_' + @tableName + N' ON ' + @tableName + N' FOR INSERT
    AS
    BEGIN
        PRINT @tableName
    END'

... which can then accept the parameter you pass through ...

EXEC sp_executesql @sql, N'@tableName sysname', @tableName=@tableName ;

Again, I'd use some heavy validation (and white-listing) before passing anything into dynamic SQL that will use a dynamic table name.

NOTE: As noted below, I believe you are limited on DML statements that can be executed with sp_executesql(), and I think parameterization is limited also. And based on your other comments, it doesn't sound like you're really needing a dynamic process but a way to repeat a specific task for a handful of elements. If that's the case, my recommendation is to do it manually with a copy/paste then execute the statements.

Shawn
  • 4,542
  • 1
  • 19
  • 28
  • I still get the same error with your code. See my updates to the question for specifics. – bdukes Jan 30 '19 at 18:46
  • 1
    I think you are limited on what DML statements you can execute in `sp_executesql`, especially with parameters. This will run for simpler statements, but I'm not sure about creating triggers. – Shawn Jan 30 '19 at 21:23
2

Since the SQL runs fine either directly or when not supplying parameters to sp_executesql, this seems like I'm running into a true limitation in the SQL engine, but I don't see it documented anywhere.

This behavior is documented, albeit not intuitive. The relevant excerpt from the documentation under the trigger limitations topic:

CREATE TRIGGER must be the first statement in the batch

When you execute a parameterized query, the parameter declarations are counted as being part of the batch. Consequently, a CREATE TRIGGER batch (and other CREATE statements for programmability objects like procs, functions, etc.) cannot be executed as a parameterized query.

The invalid syntax error message you get when you attempt to run CREATE TRIGGER as a parameterized query isn't particularly helpful. Below is an simplified version of your code using the undocumented and unsupported internal parameterized query syntax.

EXECUTE(N'(@tableName sysname = N''MyTable'')CREATE TRIGGER TR_MyTable ON dbo.MyTable FOR INSERT AS');

This at least yields an error calling out the CREATE TRIGGER limitation:

Msg 1050, Level 15, State 1, Line 73 This syntax is only allowed for parameterized queries. Msg 111, Level 15, State 1, Line 73 'CREATE TRIGGER' must be the first statement in a query batch.

Similarly executing another parameterized statement with this method runs successfully:

EXECUTE (N'(@tableName sysname = N''MyTable'')PRINT @tableName');

But if you don't actually use the parameter in the batch, an error results

EXECUTE (N'(@tableName sysname = N''MyTable'')PRINT ''done''');

Msg 1050, Level 15, State 1, Line 75 This syntax is only allowed for parameterized queries.

The bottom line is that you need to build the CREATE TRIGGER statement as a string without parameters and execute the statement as a non-parameterized query to create a trigger.

Dan Guzman
  • 35,410
  • 3
  • 30
  • 55
2

Is it possible to issue CREATE statements using sp_executesql with parameters?

Simple answer is "No", you can't

According to MSDN

Generally, parameters are valid only in Data Manipulation Language (DML) statements, and not in Data Definition Language (DDL) statements

You can check more details about this Statement Parameters

What is the issue?

Parameters are only allowed in place of scalar literals, like quoted strings or dates, or numeric values. You can't parameterise a DDL operation.

What can be done?

I believe that you want to use parameterized sp_executesql is to avoid any SQL Injection Attack. To achieve this for the DDL operations you can do following thing to minimize the possibility of attack.

  1. Use Delimiters : You can use QUOTENAME() for SYSNAME parameters like Trigger Name, Table Names and Column names.
  2. Limiting Permissions : User Account you are using to run the dynamic DDL, should have only limited permission. Like on a specific schema with only CREATE permission.
  3. Hiding Error Message : Don't throw the actual error to the user. SQL Injection are mainly performed by trial and error approach. If you hide the actual error message, it will become tough to crack it.
  4. Input Validation : You can always have a function which validates the input string, escape the required characters, check for specific keywords like DROP.

Any workaround?

If you want to parameterized your statement using sp_executesql, in that case you can get the query to be executed in a OUTPUT variable and run the query in next statement like following.

By this, the first call to sp_executesql will parameterized your query, and the actual execution will be performed by the second call to sp_executesql

For example.

DECLARE @TableName VARCHAR(100) = 'MyTable' 
DECLARE @returnStatement NVARCHAR(max); 
DECLARE @sql1 NVARCHAR(max)= 
N'SELECT @returnStatement = ''CREATE TRIGGER TR_''                                          
    +  @TableName + '' ON '' +  @TableName  +  '' FOR INSERT AS BEGIN PRINT 1 END'''

EXEC Sp_executesql 
  @sql1, 
  N'@returnStatement VARCHAR(MAX) OUTPUT, @TableName VARCHAR(100)', 
  @returnStatement output, 
  @TableName 

EXEC Sp_executesql @returnStatement 
PSK
  • 15,515
  • 4
  • 25
  • 38
1

Is it possible to issue CREATE statements using sp_executesql with parameters?

The answer is "Yes", but with small adjustment:

USE msdb

DECLARE @tableName sysname = 'sysjobsteps';

DECLARE @sql nvarchar(max) = N'
EXECUTE (''                              -- Added nested EXECUTE()
    CREATE TRIGGER [TR_'' + @tableName + N''] ON ['' + @tableName + N''] FOR INSERT
        AS
        BEGIN
            PRINT '''''+@tableName+'''''
        END''
        )'                            -- End of EXECUTE()


EXEC sp_executesql @sql, N'@tableName sysname', @tableName=@tableName

Adjsutments list:

  1. Extra EXECUTE involved, comment below explains why
  2. Extra square brackets added to make SQL Injections slightly harder

I'm looking for specific (ideally, documented) restrictions of sp_executesql with parameters and if there are any workarounds for those specific restrictions (beyond not using parameters)

in this case it is a limitation of DDL commands, not sp_executesql. DDL statements cannot be parametrized using variables. Microsoft documentation says:

Variables can be used only in expressions, not in place of object names or keywords. To construct dynamic SQL statements, use EXECUTE.

source: DECLARE (Transact-SQL)

Therefore, the solution with EXECUTE is provided by me as a workaround

Alexander Volok
  • 4,970
  • 3
  • 13
  • 30
0

Personally I hate triggers and try to avoid them most of the time ;)

However if you really, really need this dynamic stuff you should use sp_MSforeachtable and avoid injection (as pointed out by Shawn) at any cost:

EXEC sys.sp_MSforeachtable
  @command1 = '
        DECLARE @sql NVARCHAR(MAX)
        SET @sql = CONCAT(''CREATE TRIGGER TR_''
            , REPLACE(REPLACE(REPLACE(''?'', ''[dbo].'', ''''),''['',''''),'']'','''')
            , '' ON ? FOR INSERT
    AS
    BEGIN
        PRINT ''''?'''';
    END;'');
    EXEC sp_executesql @sql;'
  , @whereand = ' AND object_id IN (SELECT object_id FROM sys.objects
WHERE name LIKE ''%ContentItems%'')';
Shawn
  • 4,542
  • 1
  • 19
  • 28
Thailo
  • 1,137
  • 5
  • 12
  • In general I'd agree, but this is a case where triggers are the right answer. I'm not, however, trying to add them to each table, but to a limited set. The stored procedure is to simplify adding the right triggers to 15 or so tables, and I can then remove the procedure and avoid any possibility of injection. – bdukes Jan 30 '19 at 19:31
  • Check. In any case, you shouldn't have any trouble running my code if you add a check on object Id. – Thailo Jan 30 '19 at 20:38
  • I've amended my answer with the @whereand parameter. This solves your requirement of a limited set as you can put in all kinds of checks there. – Thailo Jan 30 '19 at 21:00
  • If you're just looking to do this on a handful of tables, you'd be significantly better off just writing the SQL statements yourself in SSMS, then copy/pasting it with the new name. Then you can run each batch. Keep in mind that you can run them all at the same time, but `CREATE TRIGGER` has to be the first statement in a batch. – Shawn Jan 30 '19 at 21:22
0

If you want to use the parameter as string, add double ' before and after the parameter name

like this :

DECLARE @tableName sysname = 'ContentItems'; 

DECLARE @sql nvarchar(max) = N'
        CREATE TRIGGER TR_' + @tableName + N' ON ' + @tableName + N' FOR INSERT
            AS
            BEGIN
               print ''' + @tableName
            +''' END';


    EXEC sp_executesql @sql

And if you want to use it as table name, use select instead of print ,

like this :

DECLARE @tableName sysname = 'ContentItems';

DECLARE @sql nvarchar(max) = N'
    CREATE TRIGGER TR_' + @tableName + N' ON ' + @tableName + N' FOR INSERT
        AS
        BEGIN
            select * from ' + @tableName
        +' END';


EXEC sp_executesql @sql
Stack Overflow
  • 1,647
  • 4
  • 15
  • 42