مثال های STORE PROCEDURE
خطبهخط و با یک تریس (اجرای قدمبهقدم با مقادیر مثال) همهچی رو باز میکنم تا کاملاً واضح بشه. اول کد شما رو میارم، توضیح میدم کجا نیاز به اصلاح داره، بعد دو نسخهٔ رایج (ساده — و نسخهٔ امنتر برای حالت چندردیفی) میذارم و در پایان اجرای نمونه با مقادیر واقعی تریس میکنم.
کدِ اصلی (شما)
CREATE PROCEDURE dbo.AddEmployee
@FirstName NVARCHAR(100),
@LastName NVARCHAR(100),
@Salary MONEY,
@NewEmployeeID INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.Employees (FirstName, LastName, Salary)
OUTPUT INSERTED.EmployeeID INTO @NewIDTable -- مثال جایگزین (ولی سادهتر: use SCOPE_IDENTITY)
VALUES (@FirstName, @LastName, @Salary);
-- سادهتر و رایجتر:
SET @NewEmployeeID = SCOPE_IDENTITY();
END;
نکتهٔ فوری: در این نمونه
@NewIDTableتعریف نشده — اگر بخواهید ازOUTPUT ... INTOاستفاده کنید باید قبلاً یک table variable مثلDECLARE @NewIDTable TABLE (NewEmployeeID INT)تعریف کنید. در غیر اینصورت خطا میگیرد. برای حالت تک-ردیف،SCOPE_IDENTITY()سادهتر و رایجتر است.
نسخهٔ اصلاحشده — ساده (برای یک درج تکردیف)
CREATE PROCEDURE dbo.AddEmployee
@FirstName NVARCHAR(100),
@LastName NVARCHAR(100),
@Salary MONEY,
@NewEmployeeID INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.Employees (FirstName, LastName, Salary)
VALUES (@FirstName, @LastName, @Salary);
-- مقدار ID ایجاد شده در همان scope را بگیر:
SET @NewEmployeeID = SCOPE_IDENTITY();
END;
خطبهخط (نسخهٔ ساده)
CREATE PROCEDURE dbo.AddEmployee ...
تعریف یک stored procedure در اسکیمایdboبا پارامترهای ورودی و یک پارامتر خروجی@NewEmployeeID(نوعINT).AS BEGIN…END;
بلوک بدنهٔ پروسیجر.SET NOCOUNT ON;
جلوی ارسال پیامهای سیستمی مثل"1 row(s) affected"را میگیرد. خوب است چون از نویزی که بعضی کلاینتها را نارسا میکند جلوگیری میکند و کمی کارایی شبکه را بهتر میکند.INSERT INTO dbo.Employees (...) VALUES (...);
ردیف جدید را وارد جدول میکند. اگرEmployeeIDستونIDENTITYباشد، SQL Server خودش مقدار جدید را اختصاص میدهد.SET @NewEmployeeID = SCOPE_IDENTITY();
مقدار شناسهی تولیدشده در همان scope را در پارامتر خروجی قرار میدهد. این مقدار پس از پایان پروسیجر به caller باز میگردد (اگر caller متغیر را باOUTPUTفرستاده باشد).
نسخهٔ امنتر برای حالت چند-ردیف یا وقتی میخواهیم همهٔ IDها را بگیریم (OUTPUT INTO)
اگر ممکنه چند ردیف درج بشه یا بخواهید همه شناسههای ایجادشده رو بگیرید، از OUTPUT با یک table variable استفاده کنید:
CREATE PROCEDURE dbo.AddEmployee_Output
@FirstName NVARCHAR(100),
@LastName NVARCHAR(100),
@Salary MONEY,
@NewEmployeeID INT OUTPUT -- برای حالت تک-ردیف هم میتونیم مقدار اول یا آخر رو بریزیم اینجا
AS
BEGIN
SET NOCOUNT ON;
DECLARE @NewIDs TABLE (NewEmployeeID INT);
INSERT INTO dbo.Employees (FirstName, LastName, Salary)
OUTPUT INSERTED.EmployeeID INTO @NewIDs
VALUES (@FirstName, @LastName, @Salary); -- اگر چند رکورد درج شود، @NewIDs چند ردیف خواهد داشت
-- اگر انتظار داریم فقط یک ردیف درج شده، مقدار را اینجا بگیریم:
SELECT TOP (1) @NewEmployeeID = NewEmployeeID FROM @NewIDs; -- یا براساس نیاز TOP 1 با ORDER BY
END;
تفاوتهای مهم:
OUTPUT INSERTED.EmployeeID INTO @NewIDs→ همهٔ مقدارهای identity تولیدشده را در جدول موقت@NewIDsقرار میدهد.- اگر چند ردیف درج شده،
@NewIDsشامل چند سطر میشود؛ پسSCOPE_IDENTITY()فقط عدد آخر را برمیگرداند؛ اگر نیاز به همهٔ IDها دارید حتماً ازOUTPUTاستفاده کنید.
تریس اجرا — مثال واقعی قدمبهقدم
فرض کنیم ساختار جدول به این صورت است:
Employees
---------
EmployeeID INT IDENTITY(1,1) PK
FirstName NVARCHAR(100)
LastName NVARCHAR(100)
Salary MONEY
قبل از اجرای پروسیجر جدول دارد:
| EmployeeID | FirstName | LastName | Salary |
|---|---|---|---|
| 1 | Sara | Smith | 3000 |
| 2 | John | Doe | 2500 |
| 3 | Mina | Ali | 2200 |
پس IDENTITY بعدی عدد 4 خواهد بود.
صدا زدن:
DECLARE @id INT;
EXEC dbo.AddEmployee @FirstName='Ali', @LastName='Rezaei', @Salary=2000, @NewEmployeeID=@id OUTPUT;
SELECT @id as CreatedId;
قدمبهقدم داخل پروسیجر (نسخهٔ ساده با SCOPE_IDENTITY)
- Caller مقدارهای
'Ali','Rezaei',2000را به پارامترها میفرستد؛ همچنین یک متغیر محلی@idدر caller وجود دارد که قرار است مقدار خروجی را دریافت کند. - وارد پروسیجر میشویم.
SET NOCOUNT ON;اجرا میشود — در نتیجه پیامهای"N rows affected"ارسال نمیشود. INSERT INTO dbo.Employees (...) VALUES (...)اجرا میشود.- SQL Server یک ردیف جدید میسازد و به ستون
EmployeeIDمقدار 4 اختصاص میدهد (طبق identity). - جدول بعد از درج: EmployeeIDFirstNameLastNameSalary1SaraSmith30002JohnDoe25003MinaAli22004AliRezaei2000
- SQL Server یک ردیف جدید میسازد و به ستون
SCOPE_IDENTITY()مقدار4را برمیگرداند (مقدار identity تولیدشده در همین scope).
سپسSET @NewEmployeeID = 4;اجرا میشود.- پروسیجر به caller بازمیگردد، و
@id(در caller) مقدار4را دارد. SELECT @id as CreatedId;در caller نمایش میدهدCreatedId = 4.
تریس اگر از OUTPUT INSERTED... INTO @NewIDs استفاده کنیم
با همان ورودیها:
- قبل از insert،
DECLARE @NewIDs TABLE (NewEmployeeID INT);ایجاد میشود (خالی). INSERT ... OUTPUT INSERTED.EmployeeID INTO @NewIDs VALUES (...)اجرا میشود.- پس از اجرا، جدول
@NewIDsشامل یک ردیف:NewEmployeeID = 4.
- پس از اجرا، جدول
SELECT @NewEmployeeID = NewEmployeeID FROM @NewIDs;مقدار4را در پارامتر خروجی قرار میدهد.- Caller
@id= 4 دریافت میکند.
نکات مهم، دامها و بهترین روشها
SCOPE_IDENTITY()vs@@IDENTITYvsIDENT_CURRENT('Table'):SCOPE_IDENTITY()→ مقدار identity تولیدشده در همان scope و session (توصیهشده برای اکثر موارد).@@IDENTITY→ آخرین identity تولیدشده در همان session حتی اگر توسط trigger و در scope دیگری تولید شده باشد (ممکن است ناخواسته باشد).IDENT_CURRENT('Employees')→ آخرین identity برای جدول مشخص در هر session (غیرایمن در شرایط concurrency).- نتیجه: معمولاً SCOPE_IDENTITY() بهترین گزینه است مگر نیاز خاصی داشته باشید.
- اگر ممکنه چند ردیف درج شود:
SCOPE_IDENTITY()فقط یک عدد (معمولاً آخرین) برمیگرداند — اگر بخواهید همه IDها را بگیرید، ازOUTPUT ... INTO tableVariableاستفاده کنید.
- اگر از
OUTPUTبدونINTOاستفاده کنید، مقدارها بهصورت resultset به client برمیگردند (میتوانید در برنامهٔ client بخوانید).OUTPUT INTOبرای ذخیره در متغیر جدول درون پروسیجر مفید است. - خطای نمونهٔ شما: وجود
OUTPUT INSERTED.EmployeeID INTO @NewIDTableبدونDECLARE @NewIDTable TABLE (...)منجر به خطا میشود — حتماً قبلش declare کنید. - NOCOUNT ON: پیشنهاد میشود در SPها قرار بدید مگر دلیل خاصی برای دریافت پیام “rows affected” داشته باشید.
- معمولاً خوب است SP رو درون TRY/CATCH و با تراکنش بنویسید تا در صورت خطا rollback انجام شود. مثال سریع:
BEGIN TRY
BEGIN TRAN;
INSERT ...
SET @NewEmployeeID = SCOPE_IDENTITY();
COMMIT TRAN;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRAN;
THROW; -- یا RAISERROR
END CATCH
- اگر بخواهید خروجی JSON یا resultset هم برگردانید، میتوانید علاوه بر OUTPUT پارامتر، یک
SELECTیاRETURNهم داشته باشید — اما توجه کنید که بعضی فریمورکها با پیامهای اضافی مشکل دارند؛ به همین خاطرNOCOUNT ONمفید است.
اگر دوست دارید، میتونم:
- نسخهٔ کامل و production-ready این پروسیجر (با TRY/CATCH، لاگ خطا، و گزینهٔ چند-ردیفی) بنویسم؛
- یا یک مثال client-side در T-SQL یا C# (ADO.NET) بذارم که نشان بده چطور
OUTPUT/SCOPE_IDENTITY()رو بخونید.
کد یا گزینهٔ مورد نظرتون رو بگید تا فوراً آمادهاش کنم. آیا میخواهید نسخهٔ TRY/CATCH و تراکنشیشده ببینید؟
این سپس SET @NewEmployeeID = 4; اجرا میشود. دقیقا کجاست ؟
این بخش مربوط به داخل بدنهٔ پروسیجر است، جایی که شما نوشتید:
SET @NewEmployeeID = SCOPE_IDENTITY();
مسیر اجرای واقعی
- خط
INSERT ... VALUES(...)یک ردیف در جدولEmployeesدرج میکند. فرض کردیمEmployeeIDجدید ۴ شد. - بلافاصله بعد از آن، SQL Server عبارت
SCOPE_IDENTITY()را اجرا میکند.- این تابع آخرین مقدار identity ایجادشده در همین scope و همین session را برمیگرداند.
- در سناریوی ما → مقدارش
4است.
- بعد، دستور
SET @NewEmployeeID = SCOPE_IDENTITY();اجرا میشود.- معادل است با:
SET @NewEmployeeID = 4; - یعنی مقدار متغیر/پارامتر خروجی
@NewEmployeeID(که داخل پروسیجر تعریف شده) برابر با ۴ میشود.
- معادل است با:
تفاوت پارامتر داخلی و متغیر بیرونی
- داخل پروسیجر:
@NewEmployeeIDهمان پارامتر خروجی است. - بیرون پروسیجر (caller): شما یک متغیر محلی دارید مثل
@id. وقتی پروسیجر تمام شد، SQL Server مقدار داخلی@NewEmployeeIDرا در متغیر بیرونی (@id) کپی میکند، چون شماOUTPUTمشخص کردهاید.
پس مکان دقیق اجرای «SET @NewEmployeeID = 4»
- داخل پروسیجر است.
- بعد از
INSERT، دقیقاً در خطی که نوشتید:
SET @NewEmployeeID = SCOPE_IDENTITY();
میخواهید براتون یک execution plan قدم به قدم بنویسم که نشان بدهد بعد از ورود به SP، خطبهخط چه دستوری اجرا میشود و چه مقادیر در هر مرحله ذخیره میشوند؟
میریم خیلی دقیق، خطبهخط توضیح بدیم و بعد چند تریس (اجرای قدمبهقدم با مثالهای واقعی) از سناریوهای ممکن انجام بدیم (موفق، From not found، To not found، و خطای داخلی). نهایتاً چند نکتهٔ عملی و «چطور دیباگ/تریس کنیم» هم میگم.
کد مورد نظر (مثال ۳)
CREATE PROCEDURE dbo.TransferSalary
@FromEmpID INT,
@ToEmpID INT,
@Amount MONEY
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRAN;
BEGIN TRY
UPDATE dbo.Employees
SET Salary = Salary - @Amount
WHERE EmployeeID = @FromEmpID;
IF @@ROWCOUNT = 0
THROW 50001, 'From employee not found', 1;
UPDATE dbo.Employees
SET Salary = Salary + @Amount
WHERE EmployeeID = @ToEmpID;
IF @@ROWCOUNT = 0
THROW 50002, 'To employee not found', 1;
COMMIT TRAN;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK TRAN;
-- ارسال خطا به بالا
THROW;
END CATCH
END;
توضیح خط به خط
CREATE PROCEDURE dbo.TransferSalary ...
تعریف یک stored procedure در اسکیمایdboبا سه پارامتر ورودی: شناسهٔ فرستنده، گیرنده و مبلغ. این SP هیچ مقدار خروجیای تعریف نکرده — خطاها باTHROWبه caller منتقل میشوند.AS BEGIN ... END;
بلوک بدنهٔ پروسیجر.SET NOCOUNT ON;
جلوگیری از ارسال پیامهای"N row(s) affected"به مشتری. مفید برای کاهش نویزِ شبکه و جلوگیری از مشکلات برخی کلاینتها که انتظار resultset دارند.BEGIN TRAN;
آغاز یک تراکنش صریح. اگر قبلاً تراکنشی باز نبود، این یک تراکنش جدید میسازد. اگر SP داخل تراکنش دیگری فراخوانی شود، مقدار شمارندهٔ تراکنش افزوده میشود (nested transaction counter) — اما دقت کنید ROLLBACK در هر سطح، کل تراکنش سطح بالاتر را هم برمیگرداند.BEGIN TRY…END TRY
بلوک TRY؛ اگر داخل آن هر اروری رخ دهد (یا ما باTHROWخطا بسازیم)، کنترل بهBEGIN CATCHمنتقل میشود.UPDATE dbo.Employees SET Salary = Salary - @Amount WHERE EmployeeID = @FromEmpID;
تلاش میکنیم از حساب (حقوق) کارمند@FromEmpIDمبلغ را کم کنیم. این خط همانجا یک یا صفر ردیف را update میکند.IF @@ROWCOUNT = 0 THROW 50001, 'From employee not found', 1;@@ROWCOUNTهمان تعداد ردیفهایی است که دقیقاً توسط آخرین دستور (در اینجا UPDATE) تغییر کردهاند.- اگر هیچ ردیفی تغییر نکرده باشد (یعنی کارمند با
@FromEmpIDوجود نداشته)، آنگاه خطای کاربرساخته با شماره50001پرتاب میشود و کنترل به CATCH میرود.
UPDATE dbo.Employees SET Salary = Salary + @Amount WHERE EmployeeID = @ToEmpID;
حالا مبلغ را به حساب@ToEmpIDاضافه میکنیم.IF @@ROWCOUNT = 0 THROW 50002, 'To employee not found', 1;
مشابه بالا؛ اگر گیرنده وجود نداشته باشد، خطای دیگری پرتاب میکنیم.COMMIT TRAN;
اگر تا اینجا هیچ خطایی رخ ندهد، تراکنش را نهایی میکنیم — یعنی تغییرات هر دو UPDATE به صورت دائمی ثبت میشود.END TRYوBEGIN CATCH
اگر داخل TRY خطایی (یا THROW) رخ داده باشد، بخش CATCH اجرا میشود.IF XACT_STATE() <> 0 ROLLBACK TRAN;XACT_STATE()سه مقدار ممکن دارد:1(تراکنش فعال و قابل commit)،0(هیچ تراکنشی)،-1(تراکنش «غیرقابلcommit» — یعنی باید rollback شود).- شرط
<> 0یعنی: اگر تراکنشی وجود دارد (چه قابلcommit یا غیرقابلcommit) آن را rollback کن. این محافظت میکند از تلاش برای rollback وقتی تراکنشی وجود ندارد.
THROW;
پس از rollback، خطا را دوباره به بالا (caller) پرتاب میکنیم تا caller هم از خطا باخبر شود. (THROW بدون پارامترها همان خطای جاری را مجدداً ارسال میکند.)
نکات کلیدی مفهومی
- ترتیبِ چک
@@ROWCOUNTمهم است — باید بلافاصله بعد از UPDATE خوانده شود، چون هر دستور بعدی مقدار آن را بازنویسی میکند. THROWدرون TRY باعث میشود کنترل مستقیم به CATCH برود.- اگر
BEGIN TRANدرون یک تراکنش بالاتری اجرا شده باشد،COMMITاین SP فقط یک بار شمارنده را کاهش میدهد و واقعیسازی کامل تنها وقتی رخ میدهد که همهٔ COMMITهای بیرونی هم اجرا شوند؛ ولیROLLBACKهر سطحی را به طور کامل برمیگرداند (یعنی تمام تراکنش را لغو میکند). - استفاده از
XACT_STATE()در CATCH درست است چون ممکن است خطای شدیدتری تراکنش را غیرقابلcommit کند (مقدار -1) — در هر دو حالتROLLBACKمجاز و لازم است.
تریس (اجرای قدمبهقدم) — چند سناریو با مقادیر واقعی
فرض جدول اولیه:
| EmployeeID | FirstName | Salary |
|---|---|---|
| 1 | Sara | 5000.00 |
| 2 | John | 2000.00 |
| 3 | Mina | 2200.00 |
مثال A — انتقال موفق: @FromEmpID=1, @ToEmpID=2, @Amount=1500.00
گامها:
- Caller:
EXEC dbo.TransferSalary 1, 2, 1500.00; - SP:
SET NOCOUNT ON;— پیامهای rows affected خاموش میشوند. - SP:
BEGIN TRAN;— تراکنش آغاز شد. (تغییرات تا COMMIT قابل مشاهده نیستند برای دیگر sessionها با حالت پیشفرض) - SP: اجرای
UPDATE ... WHERE EmployeeID = 1- قبلاً
Salary = 5000.00. - حساب: 5000.00 − 1500.00 = 3500.00. (محاسبهی گامبهگام: 5000 − 1000 = 4000؛ 4000 − 500 = 3500)
@@ROWCOUNT = 1(یک ردیف بروز شد).
- قبلاً
- شرط
IF @@ROWCOUNT = 0— نه، چون@@ROWCOUNT = 1، پس ادامه میدهیم. - اجرای
UPDATE ... WHERE EmployeeID = 2- قبلاً
Salary = 2000.00. - حساب: 2000.00 + 1500.00 = 3500.00.
@@ROWCOUNT = 1.
- قبلاً
- شرط
IF @@ROWCOUNT = 0— باز هم نه. COMMIT TRAN;— تراکنش تایید شده؛ حالا تغییرات دائمیاند.- خروج از SP. هیچ خطایی پرتاب نشده — caller موفقیت را دریافت میکند (بدون resultset مگر caller چیزی بخواهد).
نتیجه جدول بعد از COMMIT:
| EmployeeID | Salary |
|---|---|
| 1 | 3500.00 |
| 2 | 3500.00 |
| 3 | 2200.00 |
مثال B — From employee وجود ندارد: @FromEmpID=99, @ToEmpID=2, @Amount=1500.00
گامها:
BEGIN TRAN;اجرا میشود.UPDATE ... WHERE EmployeeID = 99— هیچ ردیفی وجود ندارد →@@ROWCOUNT = 0.IF @@ROWCOUNT = 0 THROW 50001, 'From employee not found', 1;اجرا میشود. اینTHROWباعث میشود اجرای TRY متوقف شده و کنترل به بخش CATCH منتقل شود.- در
CATCH:XACT_STATE()چک میشود. چون ماBEGIN TRANانجام دادیم و هیچ خطای سیستمی شدید رخ نداده که تراکنش را خراب کند، مقدار غالباً1است (تراکنش فعال و قابل commit). بنابراین شرط<> 0صحیح شده وROLLBACK TRAN;اجرا میشود — تراکنش برگردانده میشود (در اینجا عملاً چیزی برای rollback نبود چون update تاثیری نداشت). THROW;خطا (50001) را دوباره به caller میفرستد.- Caller پیغام خطا را دریافت میکند و باید آن را هندل کند.
نتیجه جدول: بدون تغییر (از آنجا که rollback شد یا عملاً هیچ بهروزرسانی نشده بود).
مثال C — To employee وجود ندارد (حالت partial): @FromEmpID=1, @ToEmpID=99, @Amount=1500.00
گامها:
BEGIN TRAN;UPDATE ... WHERE EmployeeID = 1→ موفق:Salaryاز 5000 → 3500;@@ROWCOUNT = 1.- ادامه →
UPDATE ... WHERE EmployeeID = 99→ هیچ ردیفی تغییر نکرد →@@ROWCOUNT = 0. IF @@ROWCOUNT = 0 THROW 50002, 'To employee not found', 1;→ پرتاب خطا و پرش به CATCH.- در
CATCH:XACT_STATE() <> 0→ مقدار معمولاً1(یا در برخی خطاها-1)؛ شرط برقرار است →ROLLBACK TRAN;اجرا میشود. این عمل باعث میشود تغییر مرحلهٔ اول (کسر از فرستنده) برگردانده شود و وضعیت جدول به حالت اولیه بازگردد. THROW;خطا را به caller میدهد.
نتیجه جدول: وضعیت نهایی همان وضعیت اولیه (5000 و 2000) — چون rollback انجام شد.
مثال D — خطای جدی (مثلاً deadlock یا ارور سیستمی) که تراکنش را غیرقابلcommit میکند
- ممکن است در طی اجرا خطایی با شدت بالا رخ دهد که
XACT_STATE()را به-1تبدیل کند (یعنی تراکنش غیرقابلcommit است). مثال: deadlock با خطای severity بالا یا خطای constraint جدی. - در CATCH:
IF XACT_STATE() <> 0 ROLLBACK TRAN;— چون مقدار-1هم شرط<> 0را میسازد، rollback اجرا میشود (مجبوریم rollback کنیم). سپسTHROW;خطا را رها میکند.
موارد عملی / بهترین روشها و پیشنهادات
- بررسی موجودی/کافی بودن مبلغ قبل از کم کردن
اگر میخواهید از منفی شدن حقوق جلوگیری کنید، بهتر است شرط را در UPDATE یا قبل از UPDATE اضافه کنید:UPDATE dbo.Employees SET Salary = Salary - @Amount WHERE EmployeeID = @FromEmpID AND Salary >= @Amount; IF @@ROWCOUNT = 0 THROW 50003, 'From employee not found or insufficient funds', 1; - اگر SP ممکن است درون تراکنش بیرونی فراخوانی شود
توجه کنیدBEGIN TRAN/COMMITدرون SP میتواند با تراکنش بیرونی تداخل کند. یکی از راهها این است که:- اجازه دهید caller تراکنش را مدیریت کند (SP بدون BEGIN/COMMIT).
- یا از
SAVE TRAN(savepoint) استفاده کنید تا بتوانید محلی rollback بزنید بدون اینکه کل تراکنش بالاتر را از بین ببرید. (اما ROLLBACK کامل همیشه کل تراکنش را برمیگرداند.)
- برای دیباگ / تریس ساده داخل SP
- موقتا میتوانید از
RAISERROR('step 1 reached', 0, 1) WITH NOWAIT;یاPRINTاستفاده کنید تا پیامها را بلافاصله به client ببینید. - یا از SQL Server Profiler / Extended Events برای ضبط اجراهای SP استفاده کنید (events مثل
rpc_starting,sql_statement_completed,error_reported).
- موقتا میتوانید از
- ثبت لاگ تغییرات (audit)
اگر لازم است سوابق انتقال ذخیره شود (برای گزارش/برگشت)، قبل از COMMIT رکوردی در جدول لاگ درج کنید یا ازOUTPUTاستفاده کنید تا رکورد پرداختها را ثبت کنید. - THROW vs RAISERROR
THROWساده و مدرن است؛ اگر ازTHROW;بدون پارامتر در CATCH استفاده کنید، خطای اصلی حفظ میشود.- برای صادر کردن یک خطای جدید داخل TRY،
THROW 50001, 'msg', 1;درست است (شمارههای خطای کاربری معمولاً از 50000 به بالا هستند).
نمونهٔ دیباگ/تریس — نسخهٔ موقت که پیامها را چاپ کند
فقط برای debugging — در production بهتر حذف شود.
CREATE PROCEDURE dbo.TransferSalary_Debug
@FromEmpID INT,
@ToEmpID INT,
@Amount MONEY
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRAN;
BEGIN TRY
RAISERROR('DEBUG: before subtract. From=%d, Amount=%s', 0, 1, @FromEmpID, CONVERT(VARCHAR(30), @Amount)) WITH NOWAIT;
UPDATE dbo.Employees
SET Salary = Salary - @Amount
WHERE EmployeeID = @FromEmpID;
RAISERROR('DEBUG: after subtract. @@ROWCOUNT=%d', 0, 1, @@ROWCOUNT) WITH NOWAIT;
IF @@ROWCOUNT = 0
THROW 50001, 'From employee not found', 1;
UPDATE dbo.Employees
SET Salary = Salary + @Amount
WHERE EmployeeID = @ToEmpID;
RAISERROR('DEBUG: after add. @@ROWCOUNT=%d', 0, 1, @@ROWCOUNT) WITH NOWAIT;
IF @@ROWCOUNT = 0
THROW 50002, 'To employee not found', 1;
COMMIT TRAN;
RAISERROR('DEBUG: commit ok', 0, 1) WITH NOWAIT;
END TRY
BEGIN CATCH
RAISERROR('DEBUG: in CATCH. XACT_STATE=%d', 0, 1, XACT_STATE()) WITH NOWAIT;
IF XACT_STATE() <> 0
ROLLBACK TRAN;
THROW;
END CATCH
END;
بررسی یک sp
کدِ اصلی
CREATE PROCEDURE dbo.DeleteEmployeeSafe
@EmployeeID INT
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS(SELECT 1 FROM dbo.Orders WHERE EmployeeID = @EmployeeID)
BEGIN
-- نمیذاریم حذف بشه، بازگرداندن کد خطا
RETURN 1; -- 0 = ok, 1 = has dependent records
END
DELETE FROM dbo.Employees WHERE EmployeeID = @EmployeeID;
RETURN 0;
END;
توضیح خط به خط
CREATE PROCEDURE dbo.DeleteEmployeeSafe @EmployeeID INT
تعریف یک stored procedure با یک پارامتر ورودی عددی (@EmployeeID). نام در اسکیمایdbo.AS BEGIN ... END;
بلوک بدنهٔ پروسیجر — همهٔ دستورات بین این دو اجرا میشوند.SET NOCOUNT ON;
جلوی ارسال پیامهای سیستمی مانند"1 row(s) affected"را میگیرد. باعث کاهش نویز خروجی و گاهی افزایش کارایی در client میشود.IF EXISTS(SELECT 1 FROM dbo.Orders WHERE EmployeeID = @EmployeeID)
بررسی میکند آیا رکورد(هایی) در جدولOrdersوجود دارد که به اینEmployeeIDوابسته باشند.EXISTSفقط true/false برمیگرداند و سریع است.BEGIN ... RETURN 1; END
اگر شرط بالا true شد، وارد این بلوک میشویم وRETURN 1;اجرا میشود.- اثر: اجرای پروسیجر فوراً متوقف میشود و مقدار عددی
1به caller بازگردانده میشود. هیچDELETEای اجرا نمیشود. - اشاره:
RETURNفقط عدد برمیگرداند — نمیتواند رشته برگرداند.
- اثر: اجرای پروسیجر فوراً متوقف میشود و مقدار عددی
DELETE FROM dbo.Employees WHERE EmployeeID = @EmployeeID;
اگر شرطEXISTSfalse بود، این خط اجرا شده و سعی میکند کارمند را حذف کند. ممکن است 0 یا 1 ردیف حذف شود (معمولاً 1 اگر وجود داشته باشد).RETURN 0;
پس از DELETE، پروسیجر مقدار0را برمیگرداند. واژهنامهٔ شما:0 = ok, 1 = has dependent records.
نکتهٔ مهم دربارهٔ معنی RETURN و پیامها
RETURNفقط عدد برمیگرداند. برای ارسال پیام (error/message) به client ازTHROWیاRAISERRORیاOUTPUTپارامتر استفاده کنید.- اگر پروسیجر هیچ
RETURNصریحی نداشته باشد، مقدار بازگشتی پیشفرض0است. RETURNاجرای پروسیجر را فوراً قطع میکند — بلافاصله خارج میشود.
تریس — اجرای قدمبهقدم با مثالهای واقعی
فرض کنیم جدولها اینگونهاند:
Employees:
| EmployeeID | FirstName |
|---|---|
| 1 | Sara |
| 2 | John |
| 5 | Ali |
Orders:
| OrderID | EmployeeID |
|---|---|
| 10 | 2 |
| 11 | 2 |
| 12 | 5 |
سناریو A — کارمند دارای سفارش (dependent) — @EmployeeID = 5
قدمها:
- Caller اجرا میکند:
EXEC @rc = dbo.DeleteEmployeeSafe @EmployeeID = 5; - وارد پروسیجر میشویم،
SET NOCOUNT ON;اجرا میشود. - شرط
IF EXISTS(SELECT 1 FROM dbo.Orders WHERE EmployeeID = 5)بررسی میشود — چون در جدول Orders رکورد OrderID=12 با EmployeeID=5 وجود دارد → TRUE. - بلوک داخل
BEGINاجرا میشود وRETURN 1;اجرا میشود.- پروسیجر فوراً خارج میشود؛ هیچ
DELETEای انجام نمیشود.
- پروسیجر فوراً خارج میشود؛ هیچ
- Caller مقدار برگشتی
@rcرا میگیرد:@rc = 1. - اگر caller
SELECT @rc;کند خروجی1را میبیند. پیغام خطا متنی ارسال نشده (تنها عدد برگشتی).
نتیجه: رکورد در Employees حذف نشد، @rc=1.
سناریو B — کارمند بدون سفارش و وجود دارد — @EmployeeID = 1
(فرض Orders هیچ رکوردی با EmployeeID=1 ندارد)
EXISTS(...)→ FALSE.DELETE FROM dbo.Employees WHERE EmployeeID = 1;اجرا میشود — یک ردیف حذف میشود.- سپس
RETURN 0;اجرا میشود. - Caller مقدار برگشتی
0میگیرد.
نتیجه: رکورد حذف شد، @rc=0.
سناریو C — کارمند وجود ندارد — @EmployeeID = 99
EXISTS(...)→ FALSE (چون Orders با EmployeeID=99 هم نیست).DELETE FROM dbo.Employees WHERE EmployeeID = 99;اجرا میشود — هیچ ردیفی حذف نمیشود (@@ROWCOUNT = 0).RETURN 0;اجرا میشود.- Caller
@rc=0میگیرد.
نکتهٔ مهم: در این پیادهسازی عدم وجود کارمند و حذف موفق هر دو باعث بازگشت 0 میشوند — یعنی ambiguity وجود دارد (نمیتوانید تفاوت حذف موفق و عدم وجود را از روی return code متوجه شوید).
دامها و نکات عملی
- ابهام دربارهٔ حالت «وجود ندارد»: همانطور که دیدیم،
RETURN 0هم برای حذف موفق و هم برای عدم وجود رکورد برمیگردد. اگر میخواهید دقیقتر باشید، باید تفکیک کنید (مثلاً کد2 = not found). - پیام متنی به caller:
RETURNنمیتواند رشته برگرداند. برای پیام ازOUTPUTپارامتر یاTHROW/RAISERRORاستفاده کنید. - مسئلهٔ تَوامی (Concurrency): بین چک
EXISTSوDELETEممکن است وضعیت تغییر کند (race). اگر میخواهید مطمئن باشید، از تراکنش و قفل مناسب یا بررسی مجدد بعد از DELETE استفاده کنید. - کلید خارجی (FK): اگر
Orders.EmployeeIDدارای FK باON DELETE NO ACTIONباشد، حتی اگر شما تلاش به DELETE کنید و رکورد وابسته وجود داشته باشد، DB خودش اجازهٔ حذف نمیدهد و خطا ایجاد میشود. در این کد شما قبل از DELETE چک میکنید که تحت این شرایط از خطا جلوگیری کنید. - ارائه پیام خطا: بهتر است برای خطاهای کاری (business rule) از کدهای برگشتی مشخص استفاده کنید و برای خطاهای سیستمی از THROW استفاده کنید تا caller بتواند تمایز دهد.
نسخهٔ پیشنهادی — بازگرداندن هم عدد و هم پیام
در ادامه نمونهای که هم کد عددی و هم پیغام متنی را با OUTPUT پارامتر برمیگرداند. این روش واضح و عملی است:
CREATE PROCEDURE dbo.DeleteEmployeeSafe2
@EmployeeID INT,
@ReturnMessage NVARCHAR(4000) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS(SELECT 1 FROM dbo.Orders WHERE EmployeeID = @EmployeeID)
BEGIN
SET @ReturnMessage = 'Cannot delete: dependent Orders exist';
RETURN 1; -- has dependent records
END
IF NOT EXISTS(SELECT 1 FROM dbo.Employees WHERE EmployeeID = @EmployeeID)
BEGIN
SET @ReturnMessage = 'Employee not found';
RETURN 2; -- not found
END
DELETE FROM dbo.Employees WHERE EmployeeID = @EmployeeID;
IF @@ROWCOUNT = 0
BEGIN
SET @ReturnMessage = 'Delete failed unexpectedly';
RETURN 3; -- failed
END
SET @ReturnMessage = 'Deleted successfully';
RETURN 0; -- ok
END;
مثالِ فراخوانی و دریافت هم کد هم پیام:
DECLARE @rc INT, @msg NVARCHAR(4000);
EXEC @rc = dbo.DeleteEmployeeSafe2 @EmployeeID = 5, @ReturnMessage = @msg OUTPUT;
SELECT @rc AS ReturnCode, @msg AS ReturnMessage;
خروجی نمونهها:
- اگر وابستگی داشت →
ReturnCode = 1,ReturnMessage = 'Cannot delete: dependent Orders exist' - اگر وجود نداشت →
ReturnCode = 2,ReturnMessage = 'Employee not found' - اگر حذف شد →
ReturnCode = 0,ReturnMessage = 'Deleted successfully'
اگر میخواهید پیام بهصورت خطا (Exception) صادر شود
گاهی میخواهید که حذف در صورت وجود وابستگی بهعنوان یک خطا (با severity) بالا به caller پرتاب شود تا client آنرا catch کند. نمونهٔ ترکیبی با THROW:
IF EXISTS(SELECT 1 FROM dbo.Orders WHERE EmployeeID = @EmployeeID)
BEGIN
THROW 50001, 'Cannot delete employee: dependent orders exist', 1;
END
THROWباعث میشود بلافاصله به caller خطا برسد (و معمولاً اجرای SP قطع شود). در این حالت از RETURN برای وضعیت استفاده نمیکنید — خطا بهعنوان استثنا مدیریت میشود.
جمعبندی کوتاه و پیشنهاد عملی (استادانه)
- کد فعلی شما: خیلی ساده و مناسب برای زمانی که فقط یک flag (0/1) نیاز دارید، ولی ابهام در حالت «وجود نداشتن رکورد» دارد و پیام متنی ندارد.
- اگر میخواهید caller هم عدد و هم متن ببیند، از OUTPUT پارامتر برای پیام و
RETURNبرای کد استفاده کنید (مثلDeleteEmployeeSafe2بالا). - اگر میخواهید خطاهای بحرانی را بصورت exception به caller بفرستید، از
THROWاستفاده کنید. - برای production: در نظر بگیرید تراکنش/لاک/TRY-CATCH یا اعتبارسنجی بیشتر (مثلاً بررسی suficient permissions یا auditing) اضافه کنید.
میریم خطبهخط، دقیق و با تریس (اجرای قدمبهقدم با مقادیر واقعی) این نمونهٔ Dynamic SQL امن با sp_executesql را بررسی کنیم. در انتها هشدارها، نکات امنیتی، و نسخهٔ بهبودیافته/سنجیده را هم میذارم.
کدِ اصلی
CREATE PROCEDURE dbo.SearchEmployees
@ColumnName SYSNAME, -- نام ستون مثل 'FirstName'
@SearchText NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @sql NVARCHAR(MAX);
SET @sql = N'SELECT EmployeeID, FirstName, LastName FROM dbo.Employees WHERE '
+ QUOTENAME(@ColumnName) + N' LIKE @p';
EXEC sp_executesql @sql, N'@p NVARCHAR(100)', @p = N'%'+@SearchText+'%';
END;
توضیح خطبهخط (مختصر و شفاف)
CREATE PROCEDURE dbo.SearchEmployees
تعریف stored procedure با نامdbo.SearchEmployees.@ColumnName SYSNAME
پارامتر ورودی حاوی نامِ ستون که کاربر میخواهد روی آن جستجو کند. نوعSYSNAMEمناسبِ نام اشیاء در SQL Server است.@SearchText NVARCHAR(100)
متن جستجو (مقداری که قرار است در ستون پیدا شود).SET NOCOUNT ON;
پیامهای"N row(s) affected"به caller ارسال نشود — خوب است برای کمصدایی و کارایی.DECLARE @sql NVARCHAR(MAX);
متغیری برای نگهداری رشتهٔ Dynamic SQL.SET @sql = N'SELECT ... WHERE ' + QUOTENAME(@ColumnName) + N' LIKE @p';- اینجا رشتهٔ پرس و جو ساخته میشود با ترکیب ثابت (
SELECT EmployeeID... WHERE) و نام ستون که باQUOTENAME()امنسازی شده، و سپس شرطLIKE @p. - نکته مهم: مقدار جستجو پارامترایز شده (
@p) — یعنی مقدار واقعی بهصورت پارامتر جداگانه بهsp_executesqlفرستاده میشود (جلوگیری از SQL Injection برای مقادیر).
- اینجا رشتهٔ پرس و جو ساخته میشود با ترکیب ثابت (
EXEC sp_executesql @sql, N'@p NVARCHAR(100)', @p = N'%'+@SearchText+'%';sp_executesqlاجراکنندهٔ امن dynamic SQL است که میتواند پارامتر بپذیرد.- آرگومان اول: متن اسکیوال؛ دوم: تعریف پارامترها؛ سوم به بعد: مقدار پارامترها.
- این کد مقدار
@SearchTextرا در قالب'%' + @SearchText + '%'به پارامتر@pمیدهد (جستجوی contains).
QUOTENAME(@ColumnName)QUOTENAMEاسم شیء را در براکت ([...]) یا quotes مناسب قرار میدهد و کاراکتر]را داخل نام escape میکند (مثلاًcol]a→[col]]a]). این عمل جلوی خروجِ attacker از محدودهٔ نامِ شناسه را میگیرد و جلوی تزریق از طریق نام شیء را میبندد.
تریس — اجرا قدمبهقدم با مثالهای واقعی
فرضِ جدول نمونهٔ dbo.Employees (سادهشده):
| EmployeeID | FirstName | LastName |
|---|---|---|
| 1 | Ali | Rezaei |
| 2 | Sara | Smith |
| 3 | John | Doe |
سناریو 1 — جستجوی معمولی
فراخوانی:
EXEC dbo.SearchEmployees @ColumnName = 'FirstName', @SearchText = 'Ali';
مراحل داخلی و مقادیر:
@ColumnName = 'FirstName'،@SearchText = 'Ali'.QUOTENAME(@ColumnName)→[FirstName].- مقدار
@sqlساخته میشود:N'SELECT EmployeeID, FirstName, LastName FROM dbo.Employees WHERE [FirstName] LIKE @p' - فراخوانی:
EXEC sp_executesql @sql, N'@p NVARCHAR(100)', @p = N'%Ali%'; - SQL Server متن را اجرا میکند؛ پارامتر
@p = '%Ali%'جایگزین میشود در زمان اجرا (نه در رشته)، نتیجهٔ query برمیگردد:- سطر EmployeeID = 1 برمیگردد (چون FirstName = ‘Ali’).
نکته: چون مقدار جستجو پارامتر است، حتی اگر @SearchText شامل quote یا کاراکترهای خطرناک باشد، اینها تنها به عنوان داده (نه کد) منتقل میشوند — امن.
سناریو 2 — ستون نامعتبر
فراخوانی:
EXEC dbo.SearchEmployees @ColumnName = 'BadColumn', @SearchText = 'x';
مراحل:
QUOTENAME('BadColumn')→[BadColumn].- ساخته میشود:
SELECT ... FROM dbo.Employees WHERE [BadColumn] LIKE @p - هنگام اجرا SQL Server خطا میدهد: Invalid column name ‘BadColumn’ — چون ستونی با این نام وجود ندارد. خطا به caller میرسد. (برای جلوگیری از این حالت باید قبل از اجرا بررسی کنید که ستون وجود دارد.)
سناریو 3 — تلاش تزریق از طریق نام ستون (چرا QUOTENAME امن است)
- اگر نام ستون بدون QUOTENAME مستقیماً در رشته concat شود، کاراکترهای خاص میتوانند ساختار SQL را تغییر دهند (Injection).
- اما وقتی
QUOTENAMEاستفاده شود، نام در براکت قرار میگیرد و کاراکترهایی که حملهگر اضافه کند به عنوان بخشی از اسمِ شناسه درنظر گرفته شده و نمیتوانند توالی دستورات را بشکنند. - مثالِ نحویِ امنسازی:
QUOTENAME('col]name')→[col]]name](داخل براکت]با]]escape میشود) — بنابراین حملهگر نمیتواند براحتی براکت را ببندد و اجرای کد دلخواه انجام دهد.
جمعبندی:
QUOTENAMEنام شیء را «تبدیل به یک شناسهٔ معتبر» میکند و از حمله از طریق نام جلوگیری میکند. برای مقادیر (مثل متن جستجو) از پارامتر استفاده کنید.
نکات امنیتی و عملی (آموزشی و اجرایی)
- همیشه برای نام اشیاء (table/column) از
QUOTENAMEیا از یک whitelist استفاده کنید.QUOTENAMEکمک میکند اما وجود ستون نامعتبر منجر به خطا میشود — بهتر است قبل از اجرا وجود ستون را چک کنید یا فقط نامهای از پیشتعیینشده را بپذیرید. - برای مقادیر (user input) از پارامتر استفاده کنید (
sp_executesqlبا پارامتر) — این بخش اصلی جلوگیری از SQL Injection است. - اعتبارسنجی نام ستون (پیشنهاد خوب): قبل از اجرای dynamic SQL چک کنید:
IF NOT EXISTS ( SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.Employees') AND name = @ColumnName ) BEGIN THROW 50010, 'Invalid column name', 1; ENDیا یک whitelist صریح:IF @ColumnName NOT IN (N'FirstName', N'LastName', N'Title') THROW ... - کارایی و plan cache: هر مقدار متفاوت برای
@ColumnNameمنجر به متن SQL متفاوت میشود (چون نام ستون داخل رشته است) و بنابراین هر نام ممکن است یک plan cache entry جدید بسازد. اما پارامتر@pپارامترایز شده است و آن قسمت benefit میبرد. - LIKE با
%...%غیر-sargable است (جستجوی contains معمولاً از اندکس استفاده نمیکند) — برای جستجوی کارایی بهتر، اگر امکان دارد ازLIKE 'text%'یا Full-Text Search (CONTAINS) استفاده کنید. - مقایسهٔ نوع ستون: اگر قرار است بعضی ستونها عددی یا تاریخی باشند، بهتر است قبل از ساخت شرط
LIKEبررسی کنید که نوع ستون مناسب است؛ برای ستونهای غیرمتنی باید predicate دیگری بسازید (مثلاً=,BETWEENیا تبدیل باTRY_CONVERT). - مجوزها: اجرای dynamic SQL تحت security context کاربر اجرا میشود — دقت کنید که اجازهٔ خواندن جدول/ستون را داشته باشد.
نسخهٔ بهبودیافته (با اعتبارسنجی ستون و پیام خطا)
نمونهای که ابتدا وجود ستون در dbo.Employees را چک میکند و سپس اجرا میکند:
CREATE PROCEDURE dbo.SearchEmployees_Safe
@ColumnName SYSNAME,
@SearchText NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON;
-- 1) اعتبارسنجی: ستون موجود باشد
IF NOT EXISTS (
SELECT 1
FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.Employees') AND name = @ColumnName
)
BEGIN
THROW 50011, 'Invalid column name for dbo.Employees', 1;
END
-- 2) ساخت رشته امن با QUOTENAME
DECLARE @sql NVARCHAR(MAX) =
N'SELECT EmployeeID, FirstName, LastName FROM dbo.Employees WHERE '
+ QUOTENAME(@ColumnName) + N' LIKE @p';
-- 3) اجرای پارامتری
EXEC sp_executesql @sql, N'@p NVARCHAR(100)', @p = N'%'+@SearchText+'%';
END;
خلاصهٔ کوتاه (مثل یک استاد)
- پارامترها → برای مقادیر: همیشه از پارامتر استفاده کنید (
sp_executesqlبهترین انتخاب). - نام اشیاء → QUOTENAME یا whitelist/validation؛ QUOTENAME مانع خروجِ رشته و تزریق از طریق نام میشود ولی اگر نام اشتباه باشد خطا رخ میدهد.
- برای کارایی به الگوی LIKE توجه کنید و در صورت نیاز از full-text یا تغییر الگوریتم جستجو استفاده کنید.
اگر خواستید، براساس جدول واقعیتون:
- نسخهای میسازم که فقط از یک whitelist استفاده کند،
- یا نسخهای که نوع ستون را تشخیص داده بهصورت امن برای ستونهای عددی عمل کند،
- یا اسکریپتی برای تست حملات و نشان دادن دقیق
@sqlتولید شده (فقط برای اهداف آموزشی داخل محیط ایزوله).
بگید کدوم یک رو میخواهید تا فوراً براتون آماده کنم.
You said:
لطفا این مثال که برای یک استور پروسجر هست رو به صورت کامل و خط به خط توضیح بده و تریس کن Table-Valued Parameter (برای ارسال لیست/آرایه) ابتدا Type بساز: CREATE TYPE dbo.IdList AS TABLE (Id INT PRIMARY KEY); سپس Procedure که از آن استفاده میکند: CREATE PROCEDURE dbo.GetEmployeesByIds @Ids dbo.IdList READONLY AS BEGIN SET NOCOUNT ON; SELECT e.EmployeeID, e.FirstName, e.LastName FROM dbo.Employees e JOIN @Ids i ON e.EmployeeID = i.Id; END; نحوهی استفاده: DECLARE @t dbo.IdList; INSERT INTO @t (Id) VALUES (1),(5),(7); EXEC dbo.GetEmployeesByIds @Ids = @t;
ChatGPT said:
Thought for 15s
عالی — خیلی کامل و خطبهخط میریم جلو و بعد چند تریس (اجرای قدمبهقدم با مقادیر واقعی و حالات مرزی) میزنیم تا دقیقاً بفهمید چه اتفاقی میافته.
کد پایه
-- 1) تعریف Type (فقط یکبار در دیتابیس)
CREATE TYPE dbo.IdList AS TABLE (Id INT PRIMARY KEY);
-- 2) استفاده در Stored Procedure
CREATE PROCEDURE dbo.GetEmployeesByIds
@Ids dbo.IdList READONLY
AS
BEGIN
SET NOCOUNT ON;
SELECT e.EmployeeID, e.FirstName, e.LastName
FROM dbo.Employees e
JOIN @Ids i ON e.EmployeeID = i.Id;
END;
-- 3) مثال فراخوانی
DECLARE @t dbo.IdList;
INSERT INTO @t (Id) VALUES (1),(5),(7);
EXEC dbo.GetEmployeesByIds @Ids = @t;
توضیح خطبهخط
خط 1 — CREATE TYPE dbo.IdList AS TABLE (Id INT PRIMARY KEY);
- این یک user-defined table type میسازه.
- نوعش یک جدول (table) مجازی با ستون
Id INTاست. - گذاشتن
PRIMARY KEYروی ستون باعث اضافه شدن constraint (و در عمل یک index منطقی) روی آن میشود که میتواند join را سریعتر کند و تکرارینویسی را ممنوع کند. - این دستور را فقط یکبار در دیتابیس اجرا میکنید؛ بعد از آن میتوانید از
dbo.IdListمثل هر نوع دیگر استفاده کنید.
خط 2 — CREATE PROCEDURE dbo.GetEmployeesByIds @Ids dbo.IdList READONLY
- پارامتر
@Idsاز نوعdbo.IdListاست. - نکتهٔ مهم: پارامترهای table-valued همیشه باید
READONLYباشند — یعنی داخل پروسیجر نمیتوانید آن را تغییر (INSERT/UPDATE/DELETE) کنید. این یک محدودیت اجباری است.
داخل بدنه — SET NOCOUNT ON;
- جلوگیری از ارسال پیامهای
"N rows(s) affected". خوب برای کاهش نویز و کارایی.
SELECT … JOIN @Ids i ON e.EmployeeID = i.Id;
- محتویات
@Idsمثل یک جدول موقت (table variable) در همان scope دیده میشوند و میتوان روی آن JOIN گرفت، فیلتر زد و … - این JOIN فقط ردیفهای
Employeesرا برمیگرداند کهEmployeeIDآنها در جدول پارامتری (@Ids) موجود باشد.
فراخوانی (DECLARE + INSERT + EXEC)
- در client یا در T-SQL شما یک متغیر از آن نوع تعریف میکنید (
DECLARE @t dbo.IdList;) و آنرا با ردیفها پر میکنید. سپس آن متغیر را به عنوان پارامتر به SP پاس میدهید.
تریس (اجرای قدمبهقدم با مثال واقعی)
فرض کنید جدول dbo.Employees اینگونه است:
| EmployeeID | FirstName | LastName |
|---|---|---|
| 1 | Ali | Rezaei |
| 2 | Sara | Smith |
| 3 | John | Doe |
| 5 | Ahmad | Khan |
| 7 | Mina | Ahmadi |
و حالا دستورهای نمونه را اجرا میکنیم:
قدم 1 — DECLARE متغیر TVP
DECLARE @t dbo.IdList;
- حالا
@tیک table variable با همان ساختارIdListاست (ستونId INTو constraint primary key).
قدم 2 — INSERT مقادیر
INSERT INTO @t (Id) VALUES (1),(5),(7);
- مقدارهای 1، 5 و 7 وارد
@tمیشوند. چون در type کلید اصلی تعریف شده، درج دو مقدار یکسان باعث خطا میشود (تکراری بودن ممنوع).
حالت داخلی @t حالا:
| Id |
|---|
| 1 |
| 5 |
| 7 |
قدم 3 — EXEC پروسیجر
EXEC dbo.GetEmployeesByIds @Ids = @t;
- کنترل به داخل پروسیجر میرود؛
SET NOCOUNT ONاجرا میشود. - سپس اجرا میشود:
SELECT e.EmployeeID, e.FirstName, e.LastName
FROM dbo.Employees e
JOIN @Ids i ON e.EmployeeID = i.Id;
- موتور پایگاهداده، محتویات
@Idsرا (مثل یک جدول در حافظه) میخواند و JOIN را اجرا میکند.
نتیجهٔ Query (Resultset بازگشتی)
| EmployeeID | FirstName | LastName |
|---|---|---|
| 1 | Ali | Rezaei |
| 5 | Ahmad | Khan |
| 7 | Mina | Ahmadi |
- این سه ردیف برمیگردند چون idهایشان در
@tوجود داشت. - اگر مثلاً id=9 در
@tبود و در Employees موجود نبود، ردیفی برای 9 برنمیگشت — join تطبیقپذیر است.
سناریوهای مرزی و نکات عملی
- تکرارینویسی در ورودی
- چون در
IdListPRIMARY KEYگذاشتیم، تلاش برایINSERT INTO @t (Id) VALUES (5),(5)باعث خطای duplicate key میشود. اگر قصد دارید duplicates را بپذیرید، ازPRIMARY KEYحذف کنید (ولی اغلب بهتر است همان PK را نگه دارید).
- چون در
- خالی بودن TVP
- اگر
@tخالی باشد، JOIN هیچ ردیفی برنمیگرداند — خروجی empty resultset است.
- اگر
- تغییر داخل پروسیجر ممنوع
- اگر داخل پروسیجر تلاش کنید
INSERT INTO @Ids (...)یاDELETE FROM @Idsبکنید، خطا میگیرید چون پارامتر READONLY است.
- اگر داخل پروسیجر تلاش کنید
- عملکرد
- TVP برای ارسال لیستهای نسبتا کوچک تا متوسط بسیار کارا است و بهتر از ارسال رشتههای CSV و پارس کردن در SQL است.
- چون متن query ثابت است (نام پارامتر نوعی ثابت) و از پارامتر جدول استفاده میشود، پلن میتواند cache شود و اجرای مجدد سریعتر باشد.
- برای مقادیر خیلی بزرگ (میلیونها ردیف) ممکن است روشهای bulk (مثلاً staging table و BULK INSERT) مناسبتر باشند.
- ایندکس/constraint در Type
- قراردادن
PRIMARY KEYیاUNIQUEدر تعریف type کمک میکند تا محرکهای بهتری برای اجرا ساخته شود (مثلاً از index استفاده شود). بنابراین اگر join روی Id انجام میدهید، داشتن PK مفید است.
- قراردادن
- امنیت و نوع داده
- چون دادهها بهعنوان ردیفها و ستونها ارسال میشوند، ریسک SQL Injection که هنگام ساختن رشته dynamic وجود دارد اینجا وجود ندارد (بشرطی که اسم ستون/جداول ثابت باشد). TVP یک پارامتر است و محتوا بهصورت داده منتقل میشود.
- پشتیبانی سمت Client
- اکثر درایورها (ADO.NET, JDBC, ODBC با تنظیمات) امکان ارسال TVP را دارند. در C# معمولاً DataTable میسازید و SqlParameter با
SqlDbType.StructuredوTypeName = "dbo.IdList"میسازید.
- اکثر درایورها (ADO.NET, JDBC, ODBC با تنظیمات) امکان ارسال TVP را دارند. در C# معمولاً DataTable میسازید و SqlParameter با
نمونهٔ سریع C# (خلاصه):
var dt = new DataTable();
dt.Columns.Add("Id", typeof(int));
dt.Rows.Add(1); dt.Rows.Add(5); dt.Rows.Add(7);
using (var cmd = new SqlCommand("dbo.GetEmployeesByIds", conn))
{
cmd.CommandType = CommandType.StoredProcedure;
var p = cmd.Parameters.AddWithValue("@Ids", dt);
p.SqlDbType = SqlDbType.Structured;
p.TypeName = "dbo.IdList";
using var rdr = cmd.ExecuteReader();
// خواندن resultset...
}
مقایسهٔ خلاصه با روشهای دیگر
- CSV string + split function: پیچیدهتر، کندتر، مستعد خطا و injection (اگر درست انجام نشه).
- Temporary table (##temp یا #temp): مناسب برای very large sets یا وقتی میخواهید ردیفها را در چند proc/اسکریپت مختلف استفاده کنید؛ اما TVP راحتتر برای پاس دادن از client است و نیازی به ایجاد temp table در سرور ندارد.
- Staging table + BULK INSERT: برای مجموعههای بسیار بزرگ و بارگذاری دستهای مناسبتر است.
اگر دوست دارید، برایتان:
- یک مثال کامل C# که دیتا تولید، TVP میسازد و SP را صدا میزند، یا
- نسخهای که درون پروسیجر قبل از join مقدارها را validate کند یا لاگ کند، یا
- مقایسهٔ عملکرد TVP vs temp table با مثال و اعداد بنویسم.
کد یا مورد دلخواهتان را بدون معطلی مینویسم — بگید کدام را میخواهید.
عالی — میریم خیلی دقیق، خطبهخط توضیح میدم و چند تریس (اجرای قدمبهقدم با مثال واقعی و حالات مرزی) میزنم تا همهٔ زوایا روشن بشه. در انتها نکات عملی، دامها و نسخهٔ نمونهٔ کامل برای تست محلی میذارم.
کدِ مرجعِ شما
CREATE TABLE #Tmp (EmployeeID INT, FirstName NVARCHAR(100), LastName NVARCHAR(100));
INSERT INTO #Tmp
EXEC dbo.GetEmployeeList; -- فرض کنید این proc یک resultset با همین سه ستون برمیگرداند
SELECT * FROM #Tmp;
و برای مدیریت پروسیجرها:
ALTER PROCEDURE dbo.GetEmployeeById
@EmployeeID INT
AS
BEGIN
SET NOCOUNT ON;
-- ...
END;
DROP PROCEDURE IF EXISTS dbo.GetEmployeeById;
SELECT name, object_id, create_date
FROM sys.procedures
WHERE schema_id = SCHEMA_ID('dbo');
SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.GetEmployeeById'));
-- یا
SELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID('dbo.GetEmployeeById');
توضیح خطبهخط (INSERT … EXEC بخش اول)
CREATE TABLE #Tmp (...)- یک Temporary Table محلی به نام
#Tmpدر session جاری ساخته میشود. این جدول تا پایان session (یا تا وقتی DROP شود) وجود دارد و توسط همان session قابل مشاهده است. ستونها و نوع آنها را شما باید از قبل تعریف کنید.
- یک Temporary Table محلی به نام
INSERT INTO #Tmp- دستور
INSERT ... EXECیعنی «نتیجهٔ اجرای یک stored procedure را در جدول درج کن». مهم: نباید اولEXECرا بنویسیم — بایدINSERT INTO <table>قبلش قرار بگیره.
- دستور
EXEC dbo.GetEmployeeList;- این پروسیجر اجرا میشود و فرض میکنیم یک resultset که دقیقاً سه ستون (
EmployeeID,FirstName,LastName) برمیگرداند تولید میکند. موتور SQL آن resultset را میگیرد و سعی میکند ردیفها را در#Tmpوارد کند.
- این پروسیجر اجرا میشود و فرض میکنیم یک resultset که دقیقاً سه ستون (
SELECT * FROM #Tmp;- بعد از اجرای
INSERT ... EXEC، میتوانید محتویات#Tmpرا بخوانید — ردیفهایی که از پروسیجر آمدهاند در آن قرار دارند.
- بعد از اجرای
چه چیزهایی باید دقیقاً رعایت شود (قواعد مهم)
- جدول مقصد را حتماً قبل از
INSERT ... EXECبسازید. SQL Server از روی ساختار جدول مقصد، ستونها را به ترتیب انتظار میگیرد. - تعداد و ترتیب ستونها باید سازگار باشند. بهترین عمل این است که ساختار #Tmp دقیقاً با ستونهای برگرداندهشده پروسیجر همترازی کند (هم شمار، هم ترتیب، هم نوع یا قابل تبدیل بودن نوع). اگر سازگار نباشد احتمال خطا یا نتایج ناخواسته وجود دارد.
- پروسیجر باید حداقل یک resultset تولید کند و ترجیحاً فقط یک resultset تولید کند. اگر پروسیجر چند resultset بازگرداند، رفتار پیچیده میشود — معمولاً INSERT…EXEC برای مقدار مورد انتظار طراحی شده و داشتن چند resultset منجر به دشواری در mapping میشود. (پس بهتر است proc فقط یک SELECT اصلی برگرداند یا آن را تغییر دهید.)
- محدودهٔ temp table:
#Tmpکه در session caller ساخته شده، داخل proc قابلدسترسی است. (برعکس، اگر #Tmp را داخل proc بسازید و بعد بخواهید از caller به آن دسترسی داشته باشید، scope فرق میکند.) - پیغام خطا / exception: اگر در طول اجرای proc خطایی رخ دهد، INSERT…EXEC متوقف شده و خطا به caller برمیگردد؛ بسته به تراکنشها ممکن است ردیفهای جزئی وارد شده باشند یا نشوند — برای رفتار atomic از تراکنش و TRY/CATCH استفاده کنید.
- PERMISSIONS: کاربر اجراکننده باید اجازه اجرای پروسیجر و درج در جدول (temp table) را داشته باشد.
تریس (مثال واقعی قدمبهقدم)
فرض کنیم جدول اصلی dbo.Employees این دادهها را دارد:
| EmployeeID | FirstName | LastName |
|---|---|---|
| 1 | Ali | Rezaei |
| 2 | Sara | Smith |
| 3 | John | Doe |
و dbo.GetEmployeeList تعریف سادهای دارد که همهٔ کارمندان را برمیگرداند:
CREATE PROCEDURE dbo.GetEmployeeList
AS
BEGIN
SET NOCOUNT ON;
SELECT EmployeeID, FirstName, LastName FROM dbo.Employees;
END;
اجرای قدمبهقدم
- شما در session خود اجرا میکنید:
CREATE TABLE #Tmp (EmployeeID INT, FirstName NVARCHAR(100), LastName NVARCHAR(100));→ حالا #Tmp موجود است (خالی). - سپس:
INSERT INTO #Tmp EXEC dbo.GetEmployeeList;- SQL Server
dbo.GetEmployeeListرا اجرا میکند. GetEmployeeListیک resultset سهستونه تولید میکند (ردیفهای 1،2،3).- موتور، هر ردیف را برداشته و مطابق ستونهای #Tmp درج میکند.
- بعد از پایان
EXEC، کنترل به caller بازمیگردد و #Tmp شامل سه ردیف است.
- SQL Server
- حالا:
SELECT * FROM #Tmp;→ خروجی: EmployeeIDFirstNameLastName1AliRezaei2SaraSmith3JohnDoe - در پایان session یا با
DROP TABLE #Tmp;جدول موقتی حذف میشود.
حالات مرزی و خطاهای معمول (چه موقع چه اتفاقی میافتد)
- پروسیجر نتیجهٔ ستون بیشتری برمیگرداند
- اگر proc ستون بیشتری صادر کند و #Tmp ستون کمتری داشته باشد، معمولاً خطا رخ میدهد یا ستونهای اضافی نادیده گرفته نمیشوند — رفتار ناخواسته. (پس همیشه مطمئن شوید تعداد ستونها یکی است.)
- نوع دادهها ناسازگار است
- اگر نوع ستونی در resultset قابل تبدیل به نوع در #Tmp نباشد، در runtime خطای conversion رخ میدهد.
- پروسیجر چند resultset تولید کند
- INSERT…EXEC انتظارِ یک resultset را دارد؛ اگر چند SELECT وجود داشته باشد، mapping پیچیده میشود — معمولاً توصیه میکنیم proc را طوری بنویسید که فقط یک resultset بازگرداند.
- پروسیجر خطا بدهد وسط اجرا
- خطا به caller منعکس میشود. اگر میخواهید از وارد شدن دادههای نیمهکاره جلوگیری کنید، از
BEGIN TRAN / COMMIT / ROLLBACKوTRY/CATCHاستفاده کنید تا رفتار atomic تضمین شود.
- خطا به caller منعکس میشود. اگر میخواهید از وارد شدن دادههای نیمهکاره جلوگیری کنید، از
- نیاز به استفاده در داخل تراکنش بزرگتر
- INSERT…EXEC درون تراکنش قابل استفاده است، ولی باید مراقب deadlock و وضعیت تراکنشی باشید. اگر proc داخل خودش تراکنش باز/بسته کند، تعامل میتواند پیچیده شود.
نکات و بهترینعملها
- همیشه جدول مقصد را با ستونها و انواع دقیق بسازید. (یا حداقل لیست ستونها را مشخص کنید.)
INSERT INTO #Tmp (EmployeeID, FirstName, LastName) EXEC dbo.GetEmployeeList;این کار به خوانایی و ایمنی کمک میکند. - اگر proc ممکن است چند resultset داشته باشد، آن را بازنویسی کنید تا فقط یک SELECT نهایی داشته باشد یا از روش دیگری استفاده کنید (مثلاً تغییر proc برای نوشتن مستقیم در جدول مقصد).
- برای جلوگیری از ردیفهای نیمهواردشده، از TRY/CATCH و تراکنش استفاده کنید.
- برای تست ساختار resultset قبل از INSERT، میتوانید
SET FMTONLY ONیا بهترsp_describe_first_result_set(SQL Server 2012+) استفاده کنید تا مشخصات ستونهای resultset را ببینید. (اگر به دقت ساختار میخواهید، این ابزار مفیده.) - استفاده از SELECT INTO #Tmp از خود query (اگر امکان دارد) غالباً سادهتر و سریعتر است. اما اگر شما فقط میتوانید proc را صدا بزنید (مثلاً proc بخشی از کد موجود است)، INSERT…EXEC راهحل مناسب است.
مدیریت پروسیجرها — توضیح دستورها
ALTER PROCEDURE dbo.GetEmployeeById ...- برای تغییر تعریف یک پروسیجر موجود استفاده میشود. مزیت: با
ALTER، مجوزها و dependencyها معمولاً حفظ میشوند (برخلاف DROP + CREATE که مجوزها را پاک میکند).
- برای تغییر تعریف یک پروسیجر موجود استفاده میشود. مزیت: با
DROP PROCEDURE IF EXISTS dbo.GetEmployeeById;- در SQL Server 2016+ موجود است؛ اگر پروسیجر وجود داشته باشد آن را حذف میکند، در غیر این صورت خطا نمیدهد. برای نسخههای قدیمی از شرط
IF OBJECT_ID(...) IS NOT NULL DROP PROCEDURE ...استفاده میشد.
- در SQL Server 2016+ موجود است؛ اگر پروسیجر وجود داشته باشد آن را حذف میکند، در غیر این صورت خطا نمیدهد. برای نسخههای قدیمی از شرط
SELECT name, object_id, create_date FROM sys.procedures WHERE schema_id = SCHEMA_ID('dbo');- لیست پروسیجرهای موجود در اسکیمای dbo را نشان میدهد. (برای دیدن همهٔ پروسیجرها نیاز به دسترسی مناسب دارید.)
SELECT OBJECT_DEFINITION(OBJECT_ID('dbo.GetEmployeeById'));
یاSELECT definition FROM sys.sql_modules WHERE object_id = OBJECT_ID('dbo.GetEmployeeById');- هر دو برای مشاهدهٔ متن (تعریف) پروسیجر به کار میروند. برای دیدن متن کامل نیاز به اختیارات VIEW DEFINITION یا دسترسی مالکیتی دارید؛ در غیر این صورت ممکن است فقط اطلاعات محدودی ببینید.
نکتهٔ امنیتی/دسترسی: اگر نمیتوانید تعریف را ببینید، بخاطر مجوزهاست — نقش یا permission VIEW DEFINITION لازم است.
نکتهٔ عملی: برای بهروزرسانی پروسیجر در production بهتر ALTER PROCEDURE استفاده کنید تا مجوزها و لینکها حفظ بشه.
اسکریپت نمونهٔ کامل برای تست محلی (میتونید کپی/پیست کنید)
-- نمونه ایجاد جدول و داده
IF OBJECT_ID('dbo.Employees') IS NULL
BEGIN
CREATE TABLE dbo.Employees (
EmployeeID INT IDENTITY(1,1) PRIMARY KEY,
FirstName NVARCHAR(100),
LastName NVARCHAR(100)
);
INSERT INTO dbo.Employees (FirstName, LastName)
VALUES ('Ali','Rezaei'), ('Sara','Smith'), ('John','Doe');
END;
-- پروسیجر نمونه
IF OBJECT_ID('dbo.GetEmployeeList') IS NOT NULL
DROP PROCEDURE dbo.GetEmployeeList;
GO
CREATE PROCEDURE dbo.GetEmployeeList
AS
BEGIN
SET NOCOUNT ON;
SELECT EmployeeID, FirstName, LastName FROM dbo.Employees;
END;
GO
-- تست INSERT ... EXEC
CREATE TABLE #Tmp (EmployeeID INT, FirstName NVARCHAR(100), LastName NVARCHAR(100));
INSERT INTO #Tmp (EmployeeID, FirstName, LastName)
EXEC dbo.GetEmployeeList;
SELECT * FROM #Tmp;
DROP TABLE #Tmp;
اگر دوست دارید، فوراً براتون یکی از اینها رو اضافه کنم:
- نسخهٔ امنتر با TRY/CATCH و تراکنش دور
INSERT...EXECکه در صورت خطا rollback کنه؛ - یا نمونهای که نشان بده اگر proc چند resultset برگرداند چه اتفاقی میافتد؛
- یا بررسی خودکار schema resultset با
sp_describe_first_result_setقبل ازINSERT...EXECتا قبل از اجرا از سازگاری اطمینان حاصل کنیم.
کد مورد نظر رو بگید تا بدون پرسش اضافه فوراً براتون آماده کنم.