The OrangeGoblin
The OrangeGoblin

Reputation: 794

Using a database with Custom Validators

I want to be able to create a custom validator, that will allow me to connect to my database and tell me (for example) whether a name is unique. I used to use the [Remote] attribute in EF, but I have read that you cannot use this with Blazor.

The Validation code I have so far is this:

public class LandlordNameIsUniqueValidator : ValidationAttribute 
{  
     protected override ValidationResult IsValid(object value, ValidationContext validationContext)
     {  
            //This is always null
            var context = (ApplicationDbContext)validationContext.GetService(typeof(ApplicationDbContext));          
            var checkName = new LandlordData(context);

            var name = value.ToString();
            var nameExists = checkName.CheckNameIsUnique(name);

            if (!exists)
            {
                return null;
            }

            return new ValidationResult(ErrorMessage, new[] { validationContext.MemberName });
        }
    }

The code I use (successfully in other parts of the application) is as follows, this will return a bool:

public class LandlordData : ILandlordData
{
   private readonly ApplicationDbContext _context; 
   public LandlordData(ApplicationDbContext context)
   {
       _context = context;
   }
   
   public bool CheckNameIsUnique(string name)
   {
      var exists = _context.Landlords
         .AsNoTracking()
         .Any(x => x.LandlordName == name);
      return exists;
   }
}

In StartUp.cs is as follows:

 services.AddDbContext<ApplicationDbContext>(options =>
               options.UseSqlServer(
                   _config.GetConnectionString("DefaultConnection")),
                   ServiceLifetime.Transient);

I also have this service registered, which I use in my Blazor pages, successfully.

 services.AddTransient<ILandlordData, LandlordData>();

Despite numerous attempts and different methods, I cannot (more likely I don't know how to) inject the DbContext, so I can use the LandlordData Class to check the record.

But my ApplicationDbContext is always null!

Can anyone advise the correct approach to access my database to perform custom validation.

TIA

Upvotes: 5

Views: 1741

Answers (2)

Fordy
Fordy

Reputation: 780

GetService returns null when the validation context service provider does not have the service (DbContext) registered.

Here's a custom validator that uses a stringHelper service which is used in the validator.

Calling the validator

using Microsoft.Extensions.DependencyInjection;

...

    var serviceProvider = new ServiceCollection()
    .AddSingleton<IStringHelper, StringHelper>()
    .BuildServiceProvider();

    var context = new ValidationContext(yourObjectRequiringValidation,serviceProvider,null);
    var results = new List<ValidationResult>();
    var isValid = Validator.TryValidateObject(yourObjectRequiringValidation, context, results, true);

And the custom validator that uses string helper service:

protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
       var stringValue = value?.ToString();
        
       var stringHelper = (IStringHelper)validationContext.GetService(typeof(IStringHelper));
        
       if (stringHelper == null)
          throw new InvalidOperationException("The string helper service has not been registered in the validation context service provider and so GetService cannot find the service string helper. ");
        
       return stringHelper.IsValidString(stringValue) ? ValidationResult.Success : new ValidationResult(this.ErrorMessageString);
    
    }

Upvotes: 1

Rena
Rena

Reputation: 36645

But my ApplicationDbContext is always null!

You could refer to the official document here. It has benn said that ValidationContext.GetService is null. Injecting services for validation in the IsValid method isn't supported.

For your scenario, you need firstly read the answer to learn how to pass IServiceProvider to ValidationContext.

Detailed demo:

  1. Custom DataAnnotationsValidator

    public class DIDataAnnotationsValidator: DataAnnotationsValidator
    {
        [CascadingParameter] EditContext DICurrentEditContext { get; set; }
    
        [Inject]
        protected IServiceProvider ServiceProvider { get; set; }
        protected override void OnInitialized()
        {
            if (DICurrentEditContext == null)
            {
                throw new InvalidOperationException($"{nameof(DataAnnotationsValidator)} requires a cascading " +
                    $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DataAnnotationsValidator)} " +
                    $"inside an EditForm.");
            }
    
            DICurrentEditContext.AddDataAnnotationsValidationWithDI(ServiceProvider);
        }
    }
    
  2. Custom EditContextDataAnnotationsExtensions

    public static class EditContextDataAnnotationsExtensions
    {
        private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache
        = new ConcurrentDictionary<(Type, string), PropertyInfo>();
    
        public static EditContext AddDataAnnotationsValidationWithDI(this EditContext editContext, IServiceProvider serviceProvider)
        {
            if (editContext == null)
            {
                throw new ArgumentNullException(nameof(editContext));
            }
    
            var messages = new ValidationMessageStore(editContext);
    
            // Perform object-level validation on request
            editContext.OnValidationRequested +=
                (sender, eventArgs) => ValidateModel((EditContext)sender, serviceProvider, messages);
    
            // Perform per-field validation on each field edit
            editContext.OnFieldChanged +=
                (sender, eventArgs) => ValidateField(editContext, serviceProvider, messages, eventArgs.FieldIdentifier);
    
            return editContext;
        }
        private static void ValidateModel(EditContext editContext, IServiceProvider serviceProvider,ValidationMessageStore messages)
        {
            var validationContext = new ValidationContext(editContext.Model, serviceProvider, null);
            var validationResults = new List<ValidationResult>();
            Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
    
            // Transfer results to the ValidationMessageStore
            messages.Clear();
            foreach (var validationResult in validationResults)
            {
                foreach (var memberName in validationResult.MemberNames)
                {
                    messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
                }
            }
    
            editContext.NotifyValidationStateChanged();
        }
    
        private static void ValidateField(EditContext editContext, IServiceProvider serviceProvider, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
        {
            if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
            {
                var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
                var validationContext = new ValidationContext(fieldIdentifier.Model, serviceProvider, null)
                {
                    MemberName = propertyInfo.Name
                };
                var results = new List<ValidationResult>();
    
                Validator.TryValidateProperty(propertyValue, validationContext, results);
                messages.Clear(fieldIdentifier);
                messages.Add(fieldIdentifier, results.Select(result => result.ErrorMessage));
    
                // We have to notify even if there were no messages before and are still no messages now,
                // because the "state" that changed might be the completion of some async validation task
                editContext.NotifyValidationStateChanged();
            }
        }
    
        private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo)
        {
            var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
            if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
            {
                // DataAnnotations only validates public properties, so that's all we'll look for
                // If we can't find it, cache 'null' so we don't have to try again next time
                propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
    
                // No need to lock, because it doesn't matter if we write the same value twice
                _propertyInfoCache[cacheKey] = propertyInfo;
            }
    
            return propertyInfo != null;
        }
    
    }
    
  3. Replace DataAnnotationsValidator with DIDataAnnotationsValidator

    <EditForm Model="@book" >
        <DIDataAnnotationsValidator />   //change here
        <ValidationSummary />
        <div class="row content">
            <div class="col-md-2"><label for="Name">Name</label></div>
            <div class="col-md-3"><InputText id="name" @bind-Value="book.UserName" /></div>
            <ValidationMessage For=" (() => book.UserName)" />
    
        </div>  
        <div class="row content">
            <button type="submit">Submit</button>
        </div>
    </EditForm>
    
    @code {
        Booking book= new Booking();
    }
    
  4. Then you could use your customed validation attribute:

    public class LandlordNameIsUniqueValidator : ValidationAttribute
    {
        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            //This is always null
            var context = (LykosqlContext)validationContext.GetService(typeof(LykosqlContext));
            var checkName = new LandlordData(context);
    
            var name = value.ToString();
            var nameExists = checkName.CheckNameIsUnique(name);
    
    
            return new ValidationResult(ErrorMessage, new[] { validationContext.MemberName });
        }
    }
    
  5. Model design:

    public class Booking
    {
        public int Id { get; set; }
        [LandlordNameIsUniqueValidator]
        public string UserName { get; set; }
    }
    

Upvotes: 4

Related Questions