Κάποιοι στην κουβέντα ζητήσανε παραδείγματα.
Επειδή το συγκεκριμένο που ζητήθηκε θεωρώ ότι είναι πολύ απλό και αυτονόητο, σας δίνω ένα παράδειγμα error checking/handling σε κώδικα T-SQL.
Και από αυτό φανταστείτε τι γίνεται στην C#.
Απλά αντί για RAISERROR πέφτουν βροχή τα throw InvalidParameterException και όταν ο caller έχει κάπου bug και κάποια στιγμή κάνει invalid call το πιάνουμε αμέσως και το λύνουμε σε 30 δευτερόλεπτα.
Η τακτική που ακολουθώ πάντα είναι ότι ο έλεγχος των παραμέτρων είναι εξαντλητικός σε όλα τα "Boundaries".
Όταν ο κώδικας C# καλεί κώδικα SQL, τότε περνάμε ένα boundary, άρα θέλει εξαντλητικό έλεγχο.
Οι public methods μιας κλάσης είναι boundary.
Άρα θέλει εξαντλητικό έλεγχο και εκεί, κοκ.
Στις άλλες περιπτώσεις γίνεται απλός έλεγχος παραμέτρων με βάση την κοινή λογική και με ολίγη από Debug.Assert.
Όπως βλέπετε παρακάτω, πέρα από τις παραμέτρους, και ΚΑΘΕ statement SQL που πειράζει τη βάση ελέγχεται και βγαίνει custom μήνυμα λάθους ώστε να έχω πλήρη πληροφόρηση για το τι παίχτηκε.
Θα μπορούσα με BEGIN TRY να τα κάνω στον μισό κώδικα, αλλά μετά θα έπαιρνα ότι error message προαιρείται ο SQL server και θα ψαχνόμουν στο άπειρο.
Και σιγά μην μου έβαζε μέσα στο μήνυμα το ProcessId ή άλλες χρήσιμες πληροφορίες που μπορεί να θέλω.
Δεν χρησιμοποιώ BEGIN TRY, παρά μόνο στα upgrade scripts των βάσεων.
Μου χάλασε λίγο τη στοίχηση το copy paste, αλλά μπορείτε να δείτε ότι όλα τα error checking είναι ένα tab πιο μέσα, ώστε να μην μπερδεύονται με τον υπόλοιπο κώδικα.
Επίσης το section με τα αρχικά parameter checks ξεχωρίζει από το main body με σχόλια, ώστε γρήγορα να βρίσκω αυτό που θέλω.
Όπως βλέπετε έχει πολύ κόπο το πλήρες error checking/handling και θέλει πολύ υπομονή.
Αλλά κάθε βράδυ κοιμάμαι ύσηχος :-)
Α ναι, και μην ξεχνάτε να ελέγχετε στις stored procedures αν υπάρχει transaction :-)
Το τι γίνεται όταν ο caller "ξεχάσει" να ξεκινήσει transaction και σκάσει κάποιο λάθος στην SQL έχει πάρα πολύ "πλάκα".
Είναι από τα αγαπημένα μου horror movies.
Γι'αυτό και το συγκεκριμένο είναι και ένα από τα αυτοματοποιημένα μας test.
Enumerate all strprocs και κλήση τους με όλες τις παραμέτρους null και χωρίς transaction.
Αν δεν γίνει fail με το σωστό error message, τότε έχουμε bug.
Επίσης παρατηρήστε τη χρήση των "database enums" ώστε ο κώδικας να είναι και περισσότερο αναγνώσιμος.
Είναι απλές SQL Functions του ενός statement. Η μία return 1, η άλλη return 2, κ.ο.κ.
Κάντε μου και ένα free code review μήπως υπάρχει και κανα bug ;-)
CREATE PROCEDURE dbo.InsertAction
@a_ProcessId int,
@a_ActionKindId int,
@a_NewActionId int OUTPUT
WITH ENCRYPTION AS
BEGIN
SET NOCOUNT ON
/***************************************************
Parameter Validations
***************************************************/
-- Make sure everything is transactional no matter how we were called
IF (@@TRANCOUNT = 0)
BEGIN
RAISERROR('Invalid call. Transaction required', 11, 0) WITH SETERROR
RETURN
END
--------------------------
-- Check for NULLs
--------------------------
IF ((@a_ProcessId IS NULL) OR (@a_ActionKindId IS NULL))
BEGIN
RAISERROR('Invalid parameter. Process or ActionKind Id is NULL', 11, 0) WITH SETERROR
RETURN
END
-- Check that the process exists and is not cancelled or completed
DECLARE @ProcessState tinyint
SELECT @ProcessState = ProcessState
FROM Processes WITH (UPDLOCK, ROWLOCK)
WHERE (ID = @a_ProcessId)
IF (@@ROWCOUNT = 0)
BEGIN
-- We checked the Process Id is non-null so this means it is invalid.
RAISERROR('Invalid parameter. Process %d does not exist', 11, 0, @a_ProcessId) WITH SETERROR
RETURN
END
IF (@ProcessState IN (dbo.ProcessState_Completed(), dbo.ProcessState_Cancelled()))
BEGIN
RAISERROR('Process %d is not in a state that allows actions to be inserted.', 11, 0, @a_ProcessId) WITH SETERROR
RETURN
END
-- Make sure the process is locked
IF (dbo.fn_ProcessIsLocked(@a_ProcessId) = 0)
BEGIN
RAISERROR('Process %d is not locked.', 11, 0, @a_ProcessId) WITH SETERROR
RETURN
END
-- Pick up the stuff we need from Action Kind
DECLARE @SubProcessKindId int, @Title nvarchar(100), @Duration int, @Desc nvarchar(400), @DepartmentId int
SELECT @Title = Title,
@Desc = [Description],
@DepartmentId = DepartmentId,
@Duration = Duration,
@SubProcessKindId = SubProcessKindId
FROM ActionKinds
WHERE (ID = @a_ActionKindId)
IF (@@ROWCOUNT = 0)
BEGIN
-- We checked the ActionKind Id is non-null so this means it is invalid.
RAISERROR('Invalid parameter. ActionKind %d does not exist', 11, 0, @a_ActionKindId) WITH SETERROR
RETURN
END
/***************************************************
Do the useful work now
***************************************************/
-- Insert the action detail
INSERT INTO ActionDetails(Title, [Description])
VALUES (@Title, @Desc)
IF (@@ERROR <> 0)
BEGIN
RAISERROR('FAILED to insert Action Detail.', 11, 0) WITH SETERROR
RETURN
END
DECLARE @ActionDetailId int
SET @ActionDetailId = @@IDENTITY
-- Insert Action
INSERT INTO Actions WITH (ROWLOCK)
(
ProcessId, PKAKId, ActionKindId, ActionDetailId,
ActionState, ActualStartDate, Duration,
ActualEndDate, AssignedDepartmentId, AssignedEmployeeId, SortOrder,
-- We need to set these to a non-default value coz otherwise the
-- UI takes a long time to reload the Gantt chart while the process
-- is being edited.
ExpectedStartDate, -- NORMAL_TIMESTAMP NOT NULL DEFAULT('1/1/1980'),
ExpectedEndDate, -- NORMAL_TIMESTAMP NOT NULL DEFAULT('1/1/1980'),
PlannedStartDate, -- NORMAL_TIMESTAMP NOT NULL DEFAULT('1/1/1980'),
PlannedEndDate -- NORMAL_TIMESTAMP NOT NULL DEFAULT('1/1/1980'),
)
VALUES
(
@a_ProcessId, NULL, @a_ActionKindId, @ActionDetailId,
dbo.ActionState_Waiting(), NULL, @Duration,
NULL, @DepartmentId, NULL, -1,
GETDATE(), DATEADD(day, 1, GETDATE()),
GETDATE(), DATEADD(day, 1, GETDATE())
)
IF (@@ERROR<>0)
BEGIN
RAISERROR('Failed to insert Action', 11, 0) WITH SETERROR
RETURN
END
DECLARE @ActionId int
SET @ActionId = @@IDENTITY
SET @a_NewActionId = @ActionId
-------------------------------
-- Insert Dynamic Fields
-------------------------------
DECLARE DynamicFieldsCursor CURSOR FAST_FORWARD FOR
SELECT ActionKindDynamicFields.DynamicFieldId,
DynamicFields.FieldType,
DynamicFields.DefaultValue
FROM Actions WITH (NOLOCK)
INNER JOIN Processes WITH (NOLOCK) ON (Actions.ProcessId = Processes.ID)
INNER JOIN ActionKinds WITH (NOLOCK) ON (Actions.ActionKindId = ActionKinds.ID)
INNER JOIN ActionKindDynamicFields WITH (NOLOCK) ON (ActionKinds.ID = ActionKindDynamicFields.ActionKindId)
INNER JOIN DynamicFields WITH (NOLOCK) ON (DynamicFields.ID = ActionKindDynamicFields.DynamicFieldId)
WHERE (Actions.ID = @ActionId)
OPEN DynamicFieldsCursor
DECLARE @DynamicFieldId int, @FieldType int, @DefaultFieldValue money
FETCH NEXT FROM DynamicFieldsCursor INTO @DynamicFieldId, @FieldType, @DefaultFieldValue
WHILE (@@FETCH_STATUS = 0)
BEGIN
DECLARE @ValueToInsert nvarchar(80)
SET @ValueToInsert = NULL
SELECT @FieldType = FieldType
FROM DynamicFields WITH (NOLOCK)
WHERE ID = @DynamicFieldId
IF (@DefaultFieldValue IS NOT NULL)
BEGIN
IF (@FieldType IN (dbo.DynamicFieldType_Boolean(), dbo.DynamicFieldType_Enum(), dbo.DynamicFieldType_Integer()))
SET @ValueToInsert = CONVERT(nvarchar, CONVERT(int, @DefaultFieldValue))
ELSE IF (@FieldType = dbo.DynamicFieldType_Money())
SET @ValueToInsert = CONVERT(nvarchar, @DefaultFieldValue)
END
INSERT INTO DynamicFieldValues(DynamicFieldId, ActionId, FieldValue)
VALUES (@DynamicFieldId, @ActionId, @ValueToInsert)
IF (@@ERROR<>0)
BEGIN
RAISERROR('Failed to insert into DynamicFieldValue table', 11, 0) WITH SETERROR
RETURN
END
FETCH NEXT FROM DynamicFieldsCursor INTO @DynamicFieldId, @FieldType, @DefaultFieldValue
END
CLOSE DynamicFieldsCursor
DEALLOCATE DynamicFieldsCursor
-- Action dependencies will be handled by the caller
-- Graph data will be handled by the caller
/********************
SubProcesses
*********************/
-- Is it a subprocess action?
IF (@SubProcessKindId IS NULL)
RETURN
DECLARE @SubProcessId int, @MasterProcessId int
SET @MasterProcessId = dbo.fn_ProcessGroupMaster(@a_ProcessId)
-- In the normal InsertProcess procedure, the date we pass is the
-- planned date of the action. Currently we don't have that so we
-- will pass the current timestamp. Dates recalculation will be
-- trigger by the caller when all changes are finished.
DECLARE @SubProcessStartDate NORMAL_TIMESTAMP
SET @SubProcessStartDate = GETDATE()
EXEC InsertNewProcess @MasterProcessId,
@SubProcessKindId,
@SubProcessStartDate,
@SubProcessId OUTPUT
IF (@@ERROR <> 0)
BEGIN
RAISERROR('FAILED to insert Sub Process', 11, 0) WITH SETERROR
RETURN
END
INSERT INTO ProcessRelations(MasterProcessId, ActionId, ParentProcessId, ChildProcessId)
VALUES (@MasterProcessId, @ActionId, @a_ProcessId, @SubProcessId)
IF (@@ERROR <> 0)
BEGIN
RAISERROR('FAILED to insert into ProcessRelations', 11, 0) WITH SETERROR
RETURN
END
END
The fact that the program works is irrelevant.