5

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

Rena
  • 30,832
  • 6
  • 37
  • 72
The OrangeGoblin
  • 764
  • 2
  • 7
  • 27
  • Hi @Rena, I got pulled onto something else, I am going to give it a go tonight, so I will have a reply for you. Thanks for your help. – The OrangeGoblin Apr 16 '21 at 12:56

2 Answers2

4

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; }
    }
    
Rena
  • 30,832
  • 6
  • 37
  • 72
  • Thank you for your reply. But for me, this is far too complex to fire a validation message when something is "true". I think I need to change the question. I have since discovered you cannot use the connectivity I have in EF and use Custom Validators, I have also discovered, you should never use Fluent Validation for server side results. So I am amazed this has become so complicated. – The OrangeGoblin Apr 28 '21 at 14:32
  • You may misunderstand the code here. The code is complex that is because the default ValidationAttribute does not support getting the service. So you need to rewrite the source code to make get service work. Then your requirement will work fine in `LandlordNameIsUniqueValidator `. – Rena Apr 29 '21 at 01:31
  • I don't understand how it works, but it works really well. I need to put some more study time into validators. Thank you for your support and apologies for the long reply. – The OrangeGoblin Apr 29 '21 at 15:35
  • I was able to accomplish this with just steps 4 & 5. – David Thielen Apr 13 '23 at 16:08
1

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);
    
    }
Fordy
  • 720
  • 1
  • 7
  • 11