۱۴۰۴/۰۷/۰۲ Nebular

مثال های 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;

خط‌به‌خط (نسخهٔ ساده)

  1. CREATE PROCEDURE dbo.AddEmployee ...
    تعریف یک stored procedure در اسکیمای dbo با پارامترهای ورودی و یک پارامتر خروجی @NewEmployeeID (نوع INT).
  2. AS BEGINEND;
    بلوک بدنهٔ پروسیجر.
  3. SET NOCOUNT ON;
    جلوی ارسال پیام‌های سیستمی مثل "1 row(s) affected" را می‌گیرد. خوب است چون از نویزی که بعضی کلاینت‌ها را نارسا می‌کند جلوگیری می‌کند و کمی کارایی شبکه را بهتر می‌کند.
  4. INSERT INTO dbo.Employees (...) VALUES (...);
    ردیف جدید را وارد جدول می‌کند. اگر EmployeeID ستون IDENTITY باشد، SQL Server خودش مقدار جدید را اختصاص می‌دهد.
  5. 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

قبل از اجرای پروسیجر جدول دارد:

EmployeeIDFirstNameLastNameSalary
1SaraSmith3000
2JohnDoe2500
3MinaAli2200

پس IDENTITY بعدی عدد 4 خواهد بود.

صدا زدن:

DECLARE @id INT;
EXEC dbo.AddEmployee @FirstName='Ali', @LastName='Rezaei', @Salary=2000, @NewEmployeeID=@id OUTPUT;
SELECT @id as CreatedId;

قدم‌به‌قدم داخل پروسیجر (نسخهٔ ساده با SCOPE_IDENTITY)

  1. Caller مقدارهای 'Ali', 'Rezaei', 2000 را به پارامترها می‌فرستد؛ همچنین یک متغیر محلی @id در caller وجود دارد که قرار است مقدار خروجی را دریافت کند.
  2. وارد پروسیجر می‌شویم. SET NOCOUNT ON; اجرا می‌شود — در نتیجه پیام‌های "N rows affected" ارسال نمی‌شود.
  3. INSERT INTO dbo.Employees (...) VALUES (...) اجرا می‌شود.
    • SQL Server یک ردیف جدید می‌سازد و به ستون EmployeeID مقدار 4 اختصاص می‌دهد (طبق identity).
    • جدول بعد از درج: EmployeeIDFirstNameLastNameSalary1SaraSmith30002JohnDoe25003MinaAli22004AliRezaei2000
  4. SCOPE_IDENTITY() مقدار 4 را برمی‌گرداند (مقدار identity تولیدشده در همین scope).
    سپس SET @NewEmployeeID = 4; اجرا می‌شود.
  5. پروسیجر به caller بازمی‌گردد، و @id (در caller) مقدار 4 را دارد.
  6. SELECT @id as CreatedId; در caller نمایش می‌دهد CreatedId = 4.

تریس اگر از OUTPUT INSERTED... INTO @NewIDs استفاده کنیم

با همان ورودی‌ها:

  1. قبل از insert، DECLARE @NewIDs TABLE (NewEmployeeID INT); ایجاد می‌شود (خالی).
  2. INSERT ... OUTPUT INSERTED.EmployeeID INTO @NewIDs VALUES (...) اجرا می‌شود.
    • پس از اجرا، جدول @NewIDs شامل یک ردیف: NewEmployeeID = 4.
  3. SELECT @NewEmployeeID = NewEmployeeID FROM @NewIDs; مقدار 4 را در پارامتر خروجی قرار می‌دهد.
  4. Caller @id = 4 دریافت می‌کند.

نکات مهم، دام‌ها و بهترین روش‌ها

  1. SCOPE_IDENTITY() vs @@IDENTITY vs IDENT_CURRENT('Table'):
    • SCOPE_IDENTITY() → مقدار identity تولیدشده در همان scope و session (توصیه‌شده برای اکثر موارد).
    • @@IDENTITY → آخرین identity تولیدشده در همان session حتی اگر توسط trigger و در scope دیگری تولید شده باشد (ممکن است ناخواسته باشد).
    • IDENT_CURRENT('Employees') → آخرین identity برای جدول مشخص در هر session (غیرایمن در شرایط concurrency).
    • نتیجه: معمولاً SCOPE_IDENTITY() بهترین گزینه است مگر نیاز خاصی داشته باشید.
  2. اگر ممکنه چند ردیف درج شود:
    • SCOPE_IDENTITY() فقط یک عدد (معمولاً آخرین) برمی‌گرداند — اگر بخواهید همه IDها را بگیرید، از OUTPUT ... INTO tableVariable استفاده کنید.
  3. اگر از OUTPUT بدون INTO استفاده کنید، مقدارها به‌صورت resultset به client برمی‌گردند (می‌توانید در برنامهٔ client بخوانید). OUTPUT INTO برای ذخیره در متغیر جدول درون پروسیجر مفید است.
  4. خطای نمونهٔ شما: وجود OUTPUT INSERTED.EmployeeID INTO @NewIDTable بدون DECLARE @NewIDTable TABLE (...) منجر به خطا می‌شود — حتماً قبلش declare کنید.
  5. NOCOUNT ON: پیشنهاد می‌شود در SP‌ها قرار بدید مگر دلیل خاصی برای دریافت پیام “rows affected” داشته باشید.
  6. معمولاً خوب است 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
  1. اگر بخواهید خروجی 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();

مسیر اجرای واقعی

  1. خط INSERT ... VALUES(...) یک ردیف در جدول Employees درج می‌کند. فرض کردیم EmployeeID جدید ۴ شد.
  2. بلافاصله بعد از آن، SQL Server عبارت SCOPE_IDENTITY() را اجرا می‌کند.
    • این تابع آخرین مقدار identity ایجادشده در همین scope و همین session را برمی‌گرداند.
    • در سناریوی ما → مقدارش 4 است.
  3. بعد، دستور 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 TRYEND 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 بدون پارامترها همان خطای جاری را مجدداً ارسال می‌کند.)

نکات کلیدی مفهومی

  1. ترتیبِ چک @@ROWCOUNT مهم است — باید بلافاصله بعد از UPDATE خوانده شود، چون هر دستور بعدی مقدار آن را بازنویسی می‌کند.
  2. THROW درون TRY باعث می‌شود کنترل مستقیم به CATCH برود.
  3. اگر BEGIN TRAN درون یک تراکنش بالاتری اجرا شده باشد، COMMIT این SP فقط یک بار شمارنده را کاهش می‌دهد و واقعی‌سازی کامل تنها وقتی رخ می‌دهد که همهٔ COMMITهای بیرونی هم اجرا شوند؛ ولی ROLLBACK هر سطحی را به طور کامل برمی‌گرداند (یعنی تمام تراکنش را لغو می‌کند).
  4. استفاده از XACT_STATE() در CATCH درست است چون ممکن است خطای شدیدتری تراکنش را غیرقابل‌commit کند (مقدار -1) — در هر دو حالت ROLLBACK مجاز و لازم است.

تریس (اجرای قدم‌به‌قدم) — چند سناریو با مقادیر واقعی

فرض جدول اولیه:

EmployeeIDFirstNameSalary
1Sara5000.00
2John2000.00
3Mina2200.00

مثال A — انتقال موفق: @FromEmpID=1, @ToEmpID=2, @Amount=1500.00

گام‌ها:

  1. Caller: EXEC dbo.TransferSalary 1, 2, 1500.00;
  2. SP: SET NOCOUNT ON; — پیام‌های rows affected خاموش می‌شوند.
  3. SP: BEGIN TRAN; — تراکنش آغاز شد. (تغییرات تا COMMIT قابل مشاهده نیستند برای دیگر sessionها با حالت پیش‌فرض)
  4. SP: اجرای UPDATE ... WHERE EmployeeID = 1
    • قبلاً Salary = 5000.00.
    • حساب: 5000.00 − 1500.00 = 3500.00. (محاسبه‌ی گام‌به‌گام: 5000 − 1000 = 4000؛ 4000 − 500 = 3500)
    • @@ROWCOUNT = 1 (یک ردیف بروز شد).
  5. شرط IF @@ROWCOUNT = 0نه، چون @@ROWCOUNT = 1، پس ادامه می‌دهیم.
  6. اجرای UPDATE ... WHERE EmployeeID = 2
    • قبلاً Salary = 2000.00.
    • حساب: 2000.00 + 1500.00 = 3500.00.
    • @@ROWCOUNT = 1.
  7. شرط IF @@ROWCOUNT = 0 — باز هم نه.
  8. COMMIT TRAN; — تراکنش تایید شده؛ حالا تغییرات دائمی‌اند.
  9. خروج از SP. هیچ خطایی پرتاب نشده — caller موفقیت را دریافت می‌کند (بدون resultset مگر caller چیزی بخواهد).

نتیجه جدول بعد از COMMIT:

EmployeeIDSalary
13500.00
23500.00
32200.00

مثال B — From employee وجود ندارد: @FromEmpID=99, @ToEmpID=2, @Amount=1500.00

گام‌ها:

  1. BEGIN TRAN; اجرا می‌شود.
  2. UPDATE ... WHERE EmployeeID = 99 — هیچ ردیفی وجود ندارد → @@ROWCOUNT = 0.
  3. IF @@ROWCOUNT = 0 THROW 50001, 'From employee not found', 1; اجرا می‌شود. این THROW باعث می‌شود اجرای TRY متوقف شده و کنترل به بخش CATCH منتقل شود.
  4. در CATCH: XACT_STATE() چک می‌شود. چون ما BEGIN TRAN انجام دادیم و هیچ خطای سیستمی شدید رخ نداده که تراکنش را خراب کند، مقدار غالباً 1 است (تراکنش فعال و قابل commit). بنابراین شرط <> 0 صحیح شده و ROLLBACK TRAN; اجرا می‌شود — تراکنش برگردانده می‌شود (در اینجا عملاً چیزی برای rollback نبود چون update تاثیری نداشت).
  5. THROW; خطا (50001) را دوباره به caller می‌فرستد.
  6. Caller پیغام خطا را دریافت می‌کند و باید آن را هندل کند.

نتیجه جدول: بدون تغییر (از آن‌جا که rollback شد یا عملاً هیچ به‌روزرسانی نشده بود).


مثال C — To employee وجود ندارد (حالت partial): @FromEmpID=1, @ToEmpID=99, @Amount=1500.00

گام‌ها:

  1. BEGIN TRAN;
  2. UPDATE ... WHERE EmployeeID = 1 → موفق: Salary از 5000 → 3500; @@ROWCOUNT = 1.
  3. ادامه → UPDATE ... WHERE EmployeeID = 99 → هیچ ردیفی تغییر نکرد → @@ROWCOUNT = 0.
  4. IF @@ROWCOUNT = 0 THROW 50002, 'To employee not found', 1; → پرتاب خطا و پرش به CATCH.
  5. در CATCH: XACT_STATE() <> 0 → مقدار معمولاً 1 (یا در برخی خطاها -1)؛ شرط برقرار است → ROLLBACK TRAN; اجرا می‌شود. این عمل باعث می‌شود تغییر مرحلهٔ اول (کسر از فرستنده) برگردانده شود و وضعیت جدول به حالت اولیه بازگردد.
  6. 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; خطا را رها می‌کند.

موارد عملی / بهترین روش‌ها و پیشنهادات

  1. بررسی موجودی/کافی بودن مبلغ قبل از کم کردن
    اگر می‌خواهید از منفی شدن حقوق جلوگیری کنید، بهتر است شرط را در 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;
  2. اگر SP ممکن است درون تراکنش بیرونی فراخوانی شود
    توجه کنید BEGIN TRAN/COMMIT درون SP می‌تواند با تراکنش بیرونی تداخل کند. یکی از راه‌ها این است که:
    • اجازه دهید caller تراکنش را مدیریت کند (SP بدون BEGIN/COMMIT).
    • یا از SAVE TRAN (savepoint) استفاده کنید تا بتوانید محلی rollback بزنید بدون اینکه کل تراکنش بالاتر را از بین ببرید. (اما ROLLBACK کامل همیشه کل تراکنش را برمی‌گرداند.)
  3. برای دیباگ / تریس ساده داخل 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).
  4. ثبت لاگ تغییرات (audit)
    اگر لازم است سوابق انتقال ذخیره شود (برای گزارش/برگشت)، قبل از COMMIT رکوردی در جدول لاگ درج کنید یا از OUTPUT استفاده کنید تا رکورد پرداخت‌ها را ثبت کنید.
  5. 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;

توضیح خط به خط

  1. CREATE PROCEDURE dbo.DeleteEmployeeSafe @EmployeeID INT
    تعریف یک stored procedure با یک پارامتر ورودی عددی (@EmployeeID). نام در اسکیمای dbo.
  2. AS BEGIN ... END;
    بلوک بدنهٔ پروسیجر — همهٔ دستورات بین این دو اجرا می‌شوند.
  3. SET NOCOUNT ON;
    جلوی ارسال پیام‌های سیستمی مانند "1 row(s) affected" را می‌گیرد. باعث کاهش نویز خروجی و گاهی افزایش کارایی در client می‌شود.
  4. IF EXISTS(SELECT 1 FROM dbo.Orders WHERE EmployeeID = @EmployeeID)
    بررسی می‌کند آیا رکورد(هایی) در جدول Orders وجود دارد که به این EmployeeID وابسته باشند. EXISTS فقط true/false برمی‌گرداند و سریع است.
  5. BEGIN ... RETURN 1; END
    اگر شرط بالا true شد، وارد این بلوک می‌شویم و RETURN 1; اجرا می‌شود.
    • اثر: اجرای پروسیجر فوراً متوقف می‌شود و مقدار عددی 1 به caller بازگردانده می‌شود. هیچ DELETEای اجرا نمی‌شود.
    • اشاره: RETURN فقط عدد برمی‌گرداند — نمی‌تواند رشته برگرداند.
  6. DELETE FROM dbo.Employees WHERE EmployeeID = @EmployeeID;
    اگر شرط EXISTS false بود، این خط اجرا شده و سعی می‌کند کارمند را حذف کند. ممکن است 0 یا 1 ردیف حذف شود (معمولاً 1 اگر وجود داشته باشد).
  7. RETURN 0;
    پس از DELETE، پروسیجر مقدار 0 را برمی‌گرداند. واژه‌نامهٔ شما: 0 = ok, 1 = has dependent records.

نکتهٔ مهم دربارهٔ معنی RETURN و پیام‌ها

  • RETURN فقط عدد برمی‌گرداند. برای ارسال پیام‌ (error/message) به client از THROW یا RAISERROR یا OUTPUT پارامتر استفاده کنید.
  • اگر پروسیجر هیچ RETURN صریحی نداشته باشد، مقدار بازگشتی پیش‌فرض 0 است.
  • RETURN اجرای پروسیجر را فوراً قطع می‌کند — بلافاصله خارج می‌شود.

تریس — اجرای قدم‌به‌قدم با مثال‌های واقعی

فرض کنیم جدول‌ها این‌گونه‌اند:

Employees:

EmployeeIDFirstName
1Sara
2John
5Ali

Orders:

OrderIDEmployeeID
102
112
125

سناریو A — کارمند دارای سفارش (dependent) — @EmployeeID = 5

قدم‌ها:

  1. Caller اجرا می‌کند: EXEC @rc = dbo.DeleteEmployeeSafe @EmployeeID = 5;
  2. وارد پروسیجر می‌شویم، SET NOCOUNT ON; اجرا می‌شود.
  3. شرط IF EXISTS(SELECT 1 FROM dbo.Orders WHERE EmployeeID = 5) بررسی می‌شود — چون در جدول Orders رکورد OrderID=12 با EmployeeID=5 وجود دارد → TRUE.
  4. بلوک داخل BEGIN اجرا می‌شود و RETURN 1; اجرا می‌شود.
    • پروسیجر فوراً خارج می‌شود؛ هیچ DELETEای انجام نمی‌شود.
  5. Caller مقدار برگشتی @rc را می‌گیرد: @rc = 1.
  6. اگر caller SELECT @rc; کند خروجی 1 را می‌بیند. پیغام خطا متنی ارسال نشده (تنها عدد برگشتی).

نتیجه: رکورد در Employees حذف نشد، @rc=1.


سناریو B — کارمند بدون سفارش و وجود دارد — @EmployeeID = 1

(فرض Orders هیچ رکوردی با EmployeeID=1 ندارد)

  1. EXISTS(...) → FALSE.
  2. DELETE FROM dbo.Employees WHERE EmployeeID = 1; اجرا می‌شود — یک ردیف حذف می‌شود.
  3. سپس RETURN 0; اجرا می‌شود.
  4. Caller مقدار برگشتی 0 می‌گیرد.

نتیجه: رکورد حذف شد، @rc=0.


سناریو C — کارمند وجود ندارد — @EmployeeID = 99

  1. EXISTS(...) → FALSE (چون Orders با EmployeeID=99 هم نیست).
  2. DELETE FROM dbo.Employees WHERE EmployeeID = 99; اجرا می‌شود — هیچ ردیفی حذف نمی‌شود (@@ROWCOUNT = 0).
  3. RETURN 0; اجرا می‌شود.
  4. 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;

توضیح خط‌به‌خط (مختصر و شفاف)

  1. CREATE PROCEDURE dbo.SearchEmployees
    تعریف stored procedure با نام dbo.SearchEmployees.
  2. @ColumnName SYSNAME
    پارامتر ورودی حاوی نامِ ستون که کاربر می‌خواهد روی آن جستجو کند. نوع SYSNAME مناسبِ نام اشیاء در SQL Server است.
  3. @SearchText NVARCHAR(100)
    متن جستجو (مقداری که قرار است در ستون پیدا شود).
  4. SET NOCOUNT ON;
    پیام‌های "N row(s) affected" به caller ارسال نشود — خوب است برای کم‌صدایی و کارایی.
  5. DECLARE @sql NVARCHAR(MAX);
    متغیری برای نگهداری رشتهٔ Dynamic SQL.
  6. SET @sql = N'SELECT ... WHERE ' + QUOTENAME(@ColumnName) + N' LIKE @p';
    • اینجا رشتهٔ پرس و جو ساخته می‌شود با ترکیب ثابت (SELECT EmployeeID... WHERE ) و نام ستون که با QUOTENAME() امن‌سازی شده، و سپس شرط LIKE @p.
    • نکته مهم: مقدار جستجو پارامترایز شده (@p) — یعنی مقدار واقعی به‌صورت پارامتر جداگانه به sp_executesql فرستاده می‌شود (جلوگیری از SQL Injection برای مقادیر).
  7. EXEC sp_executesql @sql, N'@p NVARCHAR(100)', @p = N'%'+@SearchText+'%';
    • sp_executesql اجراکنندهٔ امن dynamic SQL است که می‌تواند پارامتر بپذیرد.
    • آرگومان اول: متن اس‌کیوال؛ دوم: تعریف پارامترها؛ سوم به بعد: مقدار پارامترها.
    • این کد مقدار @SearchText را در قالب '%' + @SearchText + '%' به پارامتر @p می‌دهد (جستجوی contains).
  8. QUOTENAME(@ColumnName)
    • QUOTENAME اسم شیء را در براکت ([...]) یا quotes مناسب قرار می‌دهد و کاراکتر ] را داخل نام escape می‌کند (مثلاً col]a[col]]a]). این عمل جلوی خروجِ attacker از محدودهٔ نامِ شناسه را می‌گیرد و جلوی تزریق از طریق نام شیء را می‌بندد.

تریس — اجرا قدم‌به‌قدم با مثال‌های واقعی

فرضِ جدول نمونهٔ dbo.Employees (ساده‌شده):

EmployeeIDFirstNameLastName
1AliRezaei
2SaraSmith
3JohnDoe

سناریو 1 — جستجوی معمولی

فراخوانی:

EXEC dbo.SearchEmployees @ColumnName = 'FirstName', @SearchText = 'Ali';

مراحل داخلی و مقادیر:

  1. @ColumnName = 'FirstName' ، @SearchText = 'Ali'.
  2. QUOTENAME(@ColumnName)[FirstName].
  3. مقدار @sql ساخته می‌شود: N'SELECT EmployeeID, FirstName, LastName FROM dbo.Employees WHERE [FirstName] LIKE @p'
  4. فراخوانی: EXEC sp_executesql @sql, N'@p NVARCHAR(100)', @p = N'%Ali%';
  5. SQL Server متن را اجرا می‌کند؛ پارامتر @p = '%Ali%' جایگزین می‌شود در زمان اجرا (نه در رشته)، نتیجهٔ query برمی‌گردد:
    • سطر EmployeeID = 1 برمی‌گردد (چون FirstName = ‘Ali’).

نکته: چون مقدار جستجو پارامتر است، حتی اگر @SearchText شامل quote یا کاراکترهای خطرناک باشد، این‌ها تنها به عنوان داده (نه کد) منتقل می‌شوند — امن.


سناریو 2 — ستون نامعتبر

فراخوانی:

EXEC dbo.SearchEmployees @ColumnName = 'BadColumn', @SearchText = 'x';

مراحل:

  1. QUOTENAME('BadColumn')[BadColumn].
  2. ساخته می‌شود: SELECT ... FROM dbo.Employees WHERE [BadColumn] LIKE @p
  3. هنگام اجرا SQL Server خطا می‌دهد: Invalid column name ‘BadColumn’ — چون ستونی با این نام وجود ندارد. خطا به caller می‌رسد. (برای جلوگیری از این حالت باید قبل از اجرا بررسی کنید که ستون وجود دارد.)

سناریو 3 — تلاش تزریق از طریق نام ستون (چرا QUOTENAME امن است)

  • اگر نام ستون بدون QUOTENAME مستقیماً در رشته concat شود، کاراکترهای خاص می‌توانند ساختار SQL را تغییر دهند (Injection).
  • اما وقتی QUOTENAME استفاده شود، نام در براکت قرار می‌گیرد و کاراکترهایی که حمله‌گر اضافه کند به عنوان بخشی از اسمِ شناسه‌ درنظر گرفته شده و نمی‌توانند توالی دستورات را بشکنند.
  • مثالِ نحویِ امن‌سازی: QUOTENAME('col]name')[col]]name] (داخل براکت ] با ]] escape می‌شود) — بنابراین حمله‌گر نمی‌تواند براحتی براکت را ببندد و اجرای کد دلخواه انجام دهد.

جمع‌بندی: QUOTENAME نام شیء را «تبدیل به یک شناسهٔ معتبر» می‌کند و از حمله از طریق نام جلوگیری می‌کند. برای مقادیر (مثل متن جستجو) از پارامتر استفاده کنید.


نکات امنیتی و عملی (آموزشی و اجرایی)

  1. همیشه برای نام اشیاء (table/column) از QUOTENAME یا از یک whitelist استفاده کنید. QUOTENAME کمک می‌کند اما وجود ستون نامعتبر منجر به خطا می‌شود — بهتر است قبل از اجرا وجود ستون را چک کنید یا فقط نام‌های از پیش‌تعیین‌شده را بپذیرید.
  2. برای مقادیر (user input) از پارامتر استفاده کنید (sp_executesql با پارامتر) — این بخش اصلی جلوگیری از SQL Injection است.
  3. اعتبارسنجی نام ستون (پیشنهاد خوب): قبل از اجرای 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 ...
  4. کارایی و plan cache: هر مقدار متفاوت برای @ColumnName منجر به متن SQL متفاوت می‌شود (چون نام ستون داخل رشته است) و بنابراین هر نام ممکن است یک plan cache entry جدید بسازد. اما پارامتر @p پارامترایز شده است و آن قسمت benefit می‌برد.
  5. LIKE با %...% غیر-sargable است (جستجوی contains معمولاً از اندکس استفاده نمی‌کند) — برای جستجوی کارایی بهتر، اگر امکان دارد از LIKE 'text%' یا Full-Text Search (CONTAINS) استفاده کنید.
  6. مقایسهٔ نوع ستون: اگر قرار است بعضی ستون‌ها عددی یا تاریخی باشند، بهتر است قبل از ساخت شرط LIKE بررسی کنید که نوع ستون مناسب است؛ برای ستون‌های غیرمتنی باید predicate دیگری بسازید (مثلاً =, BETWEEN یا تبدیل با TRY_CONVERT).
  7. مجوزها: اجرای 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 این‌گونه است:

EmployeeIDFirstNameLastName
1AliRezaei
2SaraSmith
3JohnDoe
5AhmadKhan
7MinaAhmadi

و حالا دستورهای نمونه را اجرا می‌کنیم:

قدم 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 بازگشتی)

EmployeeIDFirstNameLastName
1AliRezaei
5AhmadKhan
7MinaAhmadi
  • این سه ردیف برمی‌گردند چون idهایشان در @t وجود داشت.
  • اگر مثلاً id=9 در @t بود و در Employees موجود نبود، ردیفی برای 9 برنمی‌گشت — join تطبیق‌پذیر است.

سناریوهای مرزی و نکات عملی

  1. تکراری‌نویسی در ورودی
    • چون در IdList PRIMARY KEY گذاشتیم، تلاش برای INSERT INTO @t (Id) VALUES (5),(5) باعث خطای duplicate key می‌شود. اگر قصد دارید duplicates را بپذیرید، از PRIMARY KEY حذف کنید (ولی اغلب بهتر است همان PK را نگه دارید).
  2. خالی بودن TVP
    • اگر @t خالی باشد، JOIN هیچ ردیفی برنمی‌گرداند — خروجی empty resultset است.
  3. تغییر داخل پروسیجر ممنوع
    • اگر داخل پروسیجر تلاش کنید INSERT INTO @Ids (...) یا DELETE FROM @Ids بکنید، خطا می‌گیرید چون پارامتر READONLY است.
  4. عملکرد
    • TVP برای ارسال لیست‌های نسبتا کوچک تا متوسط بسیار کارا است و بهتر از ارسال رشته‌های CSV و پارس کردن در SQL است.
    • چون متن query ثابت است (نام پارامتر نوعی ثابت) و از پارامتر جدول استفاده می‌شود، پلن می‌تواند cache شود و اجرای مجدد سریع‌تر باشد.
    • برای مقادیر خیلی بزرگ (میلیون‌ها ردیف) ممکن است روش‌های bulk (مثلاً staging table و BULK INSERT) مناسب‌تر باشند.
  5. ایندکس/constraint در Type
    • قراردادن PRIMARY KEY یا UNIQUE در تعریف type کمک می‌کند تا محرک‌های بهتری برای اجرا ساخته شود (مثلاً از index استفاده شود). بنابراین اگر join روی Id انجام می‌دهید، داشتن PK مفید است.
  6. امنیت و نوع داده
    • چون داده‌ها به‌عنوان ردیف‌ها و ستون‌ها ارسال می‌شوند، ریسک SQL Injection که هنگام ساختن رشته dynamic وجود دارد اینجا وجود ندارد (بشرطی که اسم ستون/جداول ثابت باشد). TVP یک پارامتر است و محتوا به‌صورت داده منتقل می‌شود.
  7. پشتیبانی سمت Client
    • اکثر درایورها (ADO.NET, JDBC, ODBC با تنظیمات) امکان ارسال TVP را دارند. در C# معمولاً DataTable می‌سازید و SqlParameter با SqlDbType.Structured و TypeName = "dbo.IdList" می‌سازید.

نمونهٔ سریع 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 بخش اول)

  1. CREATE TABLE #Tmp (...)
    • یک Temporary Table محلی به نام #Tmp در session جاری ساخته می‌شود. این جدول تا پایان session (یا تا وقتی DROP شود) وجود دارد و توسط همان session قابل مشاهده است. ستون‌ها و نوع آنها را شما باید از قبل تعریف کنید.
  2. INSERT INTO #Tmp
    • دستور INSERT ... EXEC یعنی «نتیجهٔ اجرای یک stored procedure را در جدول درج کن». مهم: نباید اول EXEC را بنویسیم — باید INSERT INTO <table> قبلش قرار بگیره.
  3. EXEC dbo.GetEmployeeList;
    • این پروسیجر اجرا می‌شود و فرض می‌کنیم یک resultset که دقیقاً سه ستون (EmployeeID, FirstName, LastName) برمی‌گرداند تولید می‌کند. موتور SQL آن resultset را می‌گیرد و سعی می‌کند ردیف‌ها را در #Tmp وارد کند.
  4. 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 این داده‌ها را دارد:

EmployeeIDFirstNameLastName
1AliRezaei
2SaraSmith
3JohnDoe

و dbo.GetEmployeeList تعریف ساده‌ای دارد که همهٔ کارمندان را برمی‌گرداند:

CREATE PROCEDURE dbo.GetEmployeeList
AS
BEGIN
  SET NOCOUNT ON;
  SELECT EmployeeID, FirstName, LastName FROM dbo.Employees;
END;

اجرای قدم‌به‌قدم

  1. شما در session خود اجرا می‌کنید: CREATE TABLE #Tmp (EmployeeID INT, FirstName NVARCHAR(100), LastName NVARCHAR(100)); → حالا #Tmp موجود است (خالی).
  2. سپس: INSERT INTO #Tmp EXEC dbo.GetEmployeeList;
    • SQL Server dbo.GetEmployeeList را اجرا می‌کند.
    • GetEmployeeList یک resultset سه‌ستونه تولید می‌کند (ردیف‌های 1،2،3).
    • موتور، هر ردیف را برداشته و مطابق ستون‌های #Tmp درج می‌کند.
    • بعد از پایان EXEC، کنترل به caller بازمی‌گردد و #Tmp شامل سه ردیف است.
  3. حالا: SELECT * FROM #Tmp; → خروجی: EmployeeIDFirstNameLastName1AliRezaei2SaraSmith3JohnDoe
  4. در پایان session یا با DROP TABLE #Tmp; جدول موقتی حذف می‌شود.

حالات مرزی و خطاهای معمول (چه موقع چه اتفاقی می‌افتد)

  1. پروسیجر نتیجهٔ ستون بیشتری برمی‌گرداند
    • اگر proc ستون بیشتری صادر کند و #Tmp ستون کمتری داشته باشد، معمولاً خطا رخ می‌دهد یا ستون‌های اضافی نادیده گرفته نمی‌شوند — رفتار ناخواسته. (پس همیشه مطمئن شوید تعداد ستون‌ها یکی است.)
  2. نوع داده‌ها ناسازگار است
    • اگر نوع ستونی در resultset قابل تبدیل به نوع در #Tmp نباشد، در runtime خطای conversion رخ می‌دهد.
  3. پروسیجر چند resultset تولید کند
    • INSERT…EXEC انتظارِ یک resultset را دارد؛ اگر چند SELECT وجود داشته باشد، mapping پیچیده می‌شود — معمولاً توصیه می‌کنیم proc را طوری بنویسید که فقط یک resultset بازگرداند.
  4. پروسیجر خطا بدهد وسط اجرا
    • خطا به caller منعکس می‌شود. اگر می‌خواهید از وارد شدن داده‌های نیمه‌کاره جلوگیری کنید، از BEGIN TRAN / COMMIT / ROLLBACK و TRY/CATCH استفاده کنید تا رفتار atomic تضمین شود.
  5. نیاز به استفاده در داخل تراکنش بزرگتر
    • 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 راه‌حل مناسب است.

مدیریت پروسیجرها — توضیح دستورها

  1. ALTER PROCEDURE dbo.GetEmployeeById ...
    • برای تغییر تعریف یک پروسیجر موجود استفاده می‌شود. مزیت: با ALTER، مجوزها و dependencyها معمولاً حفظ می‌شوند (برخلاف DROP + CREATE که مجوزها را پاک می‌کند).
  2. DROP PROCEDURE IF EXISTS dbo.GetEmployeeById;
    • در SQL Server 2016+ موجود است؛ اگر پروسیجر وجود داشته باشد آن را حذف می‌کند، در غیر این صورت خطا نمی‌دهد. برای نسخه‌های قدیمی از شرط IF OBJECT_ID(...) IS NOT NULL DROP PROCEDURE ... استفاده می‌شد.
  3. SELECT name, object_id, create_date FROM sys.procedures WHERE schema_id = SCHEMA_ID('dbo');
    • لیست پروسیجرهای موجود در اسکیمای dbo را نشان می‌دهد. (برای دیدن همهٔ پروسیجرها نیاز به دسترسی مناسب دارید.)
  4. 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 تا قبل از اجرا از سازگاری اطمینان حاصل کنیم.

کد مورد نظر رو بگید تا بدون پرسش اضافه فوراً براتون آماده کنم.

Accept Cookies
Accept Cookies
[your-shortcode]