Saving Data & Data Validation
Introduction
Data Validation
It's a fact of life that users, either intentionally or unintentionally, will do their best to enter invalid data! We will use a combination of DataAnnotations, C# code and SQL to guard against invalid data being written to the database.
YouTube Video
Saving a new friend or family member
To allow us to test our validation methods, we will initially allow invalid data to be added to the database. To do this, add the following code to the PersonSave method.
// Insert if PersonID is empty guid.
await PersonService.PersonInsert(personaddedit);
// Re-query the people Grid
people = await PersonService.PersonList();
StateHasChanged();
// Ensures a blank form when adding
personaddedit = new Person();
// Adds defaults to blank form
personaddedit.PersonDateOfBirth = new DateTime(2000, 12, 31);
personaddedit.PersonSendReminderTo = UserEmail;Data Annotation
The first line of defence against invalid data is Data Annotations. The Code Generator we used added some basic Data Annotations to the Person.cs model, however we can improve on this in a couple of ways.
- The first is that the 'PersonSendReminderTo' column is already marked as [Required] and [StringLength (100)]. This is always an Email Address and so we can also mark it as [EmailAddress], thus ensuring that a string with a valid email address format is always entered. (In this case I think this is overkill as this will always be populated automatically with the logged-in user's email address.)
- We will add error messages to the 'FirstName' and 'LastName' columns so that should the user leave the field blank, or enter a name with too many characters, they will receive a message informing them of the cause of the problem.
Note that 'PersonDateOfBirth' is required, but there is no [Range] validation. I thought this might be possible, but I could not find a way of making the maximum date the current date. We will therefore have to tackle this problem in code.
I also thought there was a potential loophole for 'FirstName' and 'LastName': there is nothing to prevent the user from just entering one space. I was surprised, but thankful, that this is caught by the [Required] attribute.
To display error messages on our dialog form using DataAnnotations we need to
- Add error messages to PersonModel
- Add <DataAnnotationsValidator /> anywhere within the <EditForm> tags in our Dialog component
- Add <ValidationMessage...> in the <EditForm> tags, placed where we want the message displayed.
Add Error Messages to Person.cs
To add an error message to a model class takes the form:
ErrorMessage = "Your Error Message"My revised code for Person.cs looks like this:
namespace BlazorBirthdayReminders.Data
{
public class Person
{
[Required]
public Guid PersonID { get; set; }
[Required (ErrorMessage = "'First Name' is required.")]
[StringLength(50, ErrorMessage = "'First Name' has a maximum length of 50 characters.")]
public string PersonFirstName { get; set; } = String.Empty;
[Required(ErrorMessage = "'Last Name' is required.")]
[StringLength(50, ErrorMessage = "'Last Name' has a maximum length of 50 characters.")]
public string PersonLastName { get; set; } = String.Empty;
[Required (ErrorMessage = "Date of Birth is compulsory")]
public DateTime PersonDateOfBirth { get; set; }
[Required (ErrorMessage = "'Send Reminder To' is required.")]
[EmailAddress(ErrorMessage = "Invalid Email Address format.")]
[StringLength(100, ErrorMessage = "'Email' has a maximum length of 100 characters.")]
public string PersonSendReminderTo { get; set; } = String.Empty;
}
}Modify Index.razor
For data validation to take place, and for the error messages to be displayed, it is essential that
For each field being validated we need to add the following code (edited for the appropriate field). It's placement will dictate where the message is displayed. In our case I have placed the messages immediately below the relevant Text Box, but it would be possible to display all error messages at the top or bottom of the form.
<ValidationMessage For="@(() => personaddedit.PersonFirstName)" />The revised code for the Dialog component now looks like this:
<SfDialog @ref="DialogAddEditPerson" IsModal="true" Width="500px" ShowCloseIcon="true" Visible="false">
<DialogTemplates>
<Header> @HeaderText </Header>
</DialogTemplates>
<EditForm Model="@personaddedit" OnValidSubmit="@PersonSave">
<DataAnnotationsValidator/>
<div>
<SfTextBox Enabled="true" Placeholder="First Name"
FloatLabelType="@FloatLabelType.Always"
@bind-Value="personaddedit.PersonFirstName">
</SfTextBox>
<ValidationMessage For="@(() => personaddedit.PersonFirstName)" />
<SfTextBox Enabled="true" Placeholder="Last Name"
FloatLabelType="@FloatLabelType.Always"
@bind-Value="personaddedit.PersonLastName">
</SfTextBox>
<ValidationMessage For="@(() => personaddedit.PersonLastName)" />
<SfDatePicker TValue="DateTime"
Placeholder='Date of Birth'
FloatLabelType="@FloatLabelType.Auto"
@bind-Value="personaddedit.PersonDateOfBirth">
</SfDatePicker>
<ValidationMessage For="@(() => personaddedit.PersonDateOfBirth)" />
<SfTextBox Enabled="false" Placeholder="Send Reminders to:"
FloatLabelType="@FloatLabelType.Always"
@bind-Value="personaddedit.PersonSendReminderTo">
</SfTextBox>
<ValidationMessage For="@(() => personaddedit.PersonSendReminderTo)" />
</div>
<br /><br />
<div class="e-footer-content">
<div class="button-container">
<button type="submit" class="e-btn e-normal e-primary">Save</button>
<button type="button" class="e-btn e-normal" @onclick="@CloseDialog">Cancel</button>
</div>
</div>
</EditForm>
</SfDialog>Data Validation in code
As noted above, I was unable to find a way of checking a range of dates using Data Annotations with the current date. We will therefore use code to check that a 'Date of Birth' is not in the future, or that the person's age would be greater than 105. (An age I chose arbitrarily.)
To check that Date of Birth is not in the future enter the following code at the top of the 'PersonSave' method:
//In all cases check the reasonableness of the date of birth
//Make sure it's not in the future
if(personaddedit.PersonDateOfBirth> DateTime.Now)
{
WarningHeaderMessage = "Warning!";
WarningContentMessage = $"It looks like the Date of Birth is wrong. It's in the future!";
Warning.OpenDialog();
return;
}It uses a new component I have called "Warning" to let the user know of the 'error' and then 'returns' out of the PesronSave method without going through any further code.
To check that the 'Date of Birth' would not result in the person being older that 105, enter the following code after the check for a future date (this is important because subtracting a future Date of Birth from DateTime.Today would result in an error (looks like TimeSpan cannot be negative.)
//Check whether they are more than 105 years old...
DateTime zeroTime = new DateTime(1, 1, 1);
TimeSpan span = DateTime.Today - personaddedit.PersonDateOfBirth;
// Because we start at year 1 for the Gregorian calendar, we must subtract a year here.
// We need to add zeroTime because span is just a number of days (i.e. not date format)
int years = (zeroTime + span).Year - 1;
// This is easier to understand, but gives the wrong result at the boundaries
//double ageInDays = span.TotalDays;
//int ageInYears = Convert.ToInt32(ageInDays/365.25);
if(years > 105)
{
WarningHeaderMessage = "Warning!";
WarningContentMessage = $"It looks like the Date of Birth is wrong. They would be { years } old!";
Warning.OpenDialog();
return;
}Warning Component
The above code will call a new component that I've called 'Warning'. This is a blazor/razor component (like the Dialog pop-up), but as this might have application-wide use we will add it as a separate razor file.
In the Shared folder create a new Razor file an call it WarningPage.razor.
Copy and paste the following code into the new file:
@using Syncfusion.Blazor.Popups;
<SfDialog @ref="DialogWarning" @bind-Visible="@IsVisible" IsModal="true" Width="300px" ShowCloseIcon="true">
<DialogTemplates>
<Header> @WarningHeaderMessage </Header>
<Content>@WarningContentMessage</Content>
</DialogTemplates>
<DialogButtons>
<DialogButton Content="OK" IsPrimary="true" OnClick="@CloseDialog" />
</DialogButtons>
</SfDialog>
@code {
SfDialog? DialogWarning;
public bool IsVisible { get; set; } = false;
[Parameter] public string? WarningHeaderMessage { get; set; }
[Parameter] public string? WarningContentMessage { get; set; }
public void OpenDialog()
{
this.IsVisible = true;
this.StateHasChanged();
}
public void CloseDialog()
{
this.IsVisible = false;
this.StateHasChanged();
}
}Without going into too much detail, this component is a modal Syncfusion dialog with parameters for the dialog header and content. It has two methods either to open or close the dialog.
Displaying the Warning
To call this component, in Index.razor, we need to make the following modifications:
- In the HTML section, after the SfDialog for adding/editing person add the following:
<WarningPage @ref="Warning" WarningHeaderMessage="@WarningHeaderMessage" WarningContentMessage="@WarningContentMessage" />- At the top of the code section add the following declarations:
WarningPage Warning;
string WarningHeaderMessage = "";
string WarningContentMessage = "";Checking for Duplicates
A person may be known to more than one user, but I want to prevent the same person being duplicated for the same user - nobody wants to send their brother two birthday presents! We will tackle this at the SQL level by replacing the existing stored procedure for inserting a new person with the following (which drops and re-creates the revised stored procedure):
USE [Birthdays]
GO
/****** Object: StoredProcedure [dbo].[spPerson_Insert] ******/
DROP PROCEDURE [dbo].[spPerson_Insert]
GO
/****** Object: StoredProcedure [dbo].[spPerson_Insert] ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROCEDURE [dbo].[spPerson_Insert]
(
@PersonFirstName nvarchar(50),
@PersonLastName nvarchar(50),
@PersonDateOfBirth date,
@PersonSendReminderTo nvarchar(100)
)
AS
DECLARE @ResultValue int
BEGIN TRAN
IF EXISTS
(
SELECT * FROM Person
WHERE PersonFirstName = @PersonFirstName
and PersonLastName = @PersonLastName
and PersonDateOfBirth = @PersonDateOfBirth
and PersonSendReminderTo = @PersonSendReminderTo
)
BEGIN
SET @ResultValue = 99
END
ELSE
BEGIN
INSERT INTO Person(PersonFirstName, PersonLastName, PersonDateOfBirth, PersonSendReminderTo) VALUES (@PersonFirstName, @PersonLastName, @PersonDateOfBirth, @PersonSendReminderTo)
set @ResultValue = @@ERROR
END
IF @ResultValue <> 0
BEGIN
ROLLBACK TRAN
END
ELSE
BEGIN
COMMIT TRAN
END
RETURN @ResultValue
GO
This works by trying to select a record where the FirstName, LastName, DateOfBirth and SendReminderTo are all equal to the parameters being passed to the stored procedure for the new Person. If a match is found the stored procedure returns a @ResultValue of 99. If no match is found it will insert the new record (line 39) and set the @ResultValue to @@ERROR (which, rather misleadingly will be 0 to indicate that no error was found!)
Previously the Service and Interface for inserting a person were declared a booleans (either the insert worked or it didn't). Now the stored procedure will return an integer, 0 indicating success or 99 for failure. We therefore need to make changes to the PersonService and IPersonService files to accommodate these changes.
Replace the 'PersonInsert' method in PersonService with the following. The modified code declares an integer variable called 'Success' and initialises it to '99'; an additional parameter for the return value is added and this value assigned to the Success variable on completion of the stored procedure.
// Add (create) a Person table row (SQL Insert)
public async Task<int> PersonInsert(Person person)
{
int Success = 99;
using IDbConnection conn = new SqlConnection(_configuration.GetConnectionString(connectionId));
{
var parameters = new DynamicParameters();
parameters.Add("PersonFirstName", person.PersonFirstName, DbType.String);
parameters.Add("PersonLastName", person.PersonLastName, DbType.String);
parameters.Add("PersonDateOfBirth", person.PersonDateOfBirth, DbType.Date);
parameters.Add("PersonSendReminderTo", person.PersonSendReminderTo, DbType.String);
parameters.Add("@ReturnValue", dbType: DbType.Int32, direction: ParameterDirection.ReturnValue);
// Stored procedure method
await conn.ExecuteAsync("spPerson_Insert", parameters, commandType: CommandType.StoredProcedure);
Success = parameters.Get<int>("@ReturnValue");
}
return Success;
}We need to make an equivalent modification to IPersonService, as shown below, changing the type from 'bool' to 'int'.
Task<int> PersonInsert(Person person);With these changes, we can return to Index.razor and add the code to handle the insert. Place the following code below '// Insert if PersonID is empty guid' in the 'PersonSave' method.
int Success = await PersonService.PersonInsert(personaddedit);
if (Success != 0)
{
//Person already exists - warn the user
WarningHeaderMessage = "Warning!";
WarningContentMessage = "This Person already exists; it cannot be added again.";
Warning.OpenDialog();
}
else
{
//Refresh datagrid
people = await PersonService.PersonList();
StateHasChanged();
// Ensures a blank form for adding a new record
personaddedit = new Person();
//Adds defaults for a new record
personaddedit.PersonDateOfBirth = new DateTime(2000, 12, 31);
personaddedit.PersonSendReminderTo = UserEmail;
}
}
else
{
// Item is being edited
}
This declares an integer called 'Success' and sets it to the return value of the PersonService.PersonInsert. If the insert has been successfully carried out the return value will be 0 in which case 'personaddedit' is re-initialised (and defaults for 'Date of Birth' and 'Send Reminder To' reset) and the underlying grid refreshed. Note that the dialog is not closed, allowing the user to add a new contact.
If Success is not 0, i.e. a duplicate has been found, we use the Warning component, passing to the component the message that the person already exists and cannot be added again.
Run the application, log in, and test that you can add a new contact. Try to add a duplicate.
References
Code changes for this article : Saving Data and Data Validation