1

In ASP.Net EF Core I have various entity models with corresponding controllers which inherit from Microsoft.AspNetCore.DataSync.TableController and each model inherits EntityTableData from the same DataSync library so as to facilitate the Offline Sync from a MAUI client App.

The question (I think) is for EF Core (but perhaps the options are limited/directed by the Offline sync requirement both on Server/Client)... how do I reuse Lambda business logic in EF considering that entity models may not have the same property name to use in that reusable lambda.
Details below... but in short I have a User entity with an Id property and other entities with a foreign key property of UserId and would like to reuse the following logic on all of those entities... Or an alternative that achieves the same reuse aim?

/// <summary>
/// Limit data returned to client
/// </summary>
/// <returns></returns>
public override System.Linq.Expressions.Expression<Func<T, bool>> GetDataView()
{
    Logger.LogInformation($"GetDataView for {Authenticatable.DbUserId}");

    //Only records which belong to this user should ever be visible
    return Authenticatable.DbUserId == null
        ? _ => false
        //the following doesn't work for a clean 
        //user entity because it does not have 
        //a UserId property but instead an Id property!
        : model => model.UserId == Authenticatable.DbUserId;
}

The user entity cannot simply have a UserId instead of Id property because MS Azure DataSync needs an Id property/column as the unique identifier for Sync.

What I've tried... I tried a bunch of newbie mistake things such as a UserId property on the User entity which also maps to the Id column of the DB. But this results in an error because you cannot have multiple EF properties mapping to the same table column and because I must have Id inherited from MS DataSync EntityTableData.

My Hack solution My workaround is a HACK and I would prefer something that feels cleaner (and doesn't affect the client).

In this hack solution the reusable lamba (aforementioned) stipulates that T inherits from IUserAttributableEntity allowing for reuse.

public interface IUserAttributableEntity{string UserId { get; set; }}

The User entity itself gets around the problem of needing an Id property but also needing a UserId property mapping to the DB table Id like this...

public partial class User : EntityTableData,  IUserAttributableEntity
{
    // In order to map correctly the client model has
    // [JsonProperty("UserId")] on the Id property
    // This resolves 'Could not find a property named 'id' on type'
    [NotMapped]
    public override string Id { get => base.Id; set => base.Id = value; }

    /// <summary>
    /// HACK: this enables us to reused generic Access Control Provider
    ///       which expects UserId and NOT Id
    /// </summary>
    [Key]
    [Column(nameof(Id))]
    public string UserId { get => Id; set { Id = value; } }

It is essentially making the Id property from the base EntityTableData dto not mapped to the DB table Id column and instead UserId does this for me. It does work fine, but apart from feeling like a HACK it has consequences on the MAUI client which is where it feels really dirty...

The client is also using offline MS DataSync and so must also adhere to the Id property rule like the server. But, because the server DTO now maps UserId in place of Id, issues occur such as when executing a queryable e.g. await _userRepo.PullAsync(condition: f=>f.Id == userId); results in an error similar to "'id' not found". This is just an example!

Therefore, we now need to HACK the client...

public class User : BaseDTO, ILoggedInUser
{
    // JsonProperty fixes 'Could not find a property named 'id' on type'
    // when doing a PullAsyncWhere on Id.
    // Only required where corresponding server model
    // has '[NotMapped]' attribute on Id
    [JsonProperty("UserId")]
    public new string Id { get { return base.Id; } set { base.Id = value; } }

Finally, this all works. But it feels really dirty to be making changes on the server which then result in hacks on the client.

A preferred alternative would have been to leave the client completely vanilla as it should be and instead somehow force Id when it comes across the wire from the client to the server to be mapped to UserId.

I did try this on the server user entity...

...
[Key]
[Column(nameof(Id))]
[JsonProperty("Id")]
public string UserId { get => Id; set { Id = value; } }
...

... but this just failed with errors similar to 'id not found'. I also tried with lowercase...[JsonProperty("id")]. but still no cigar :(

RyanO
  • 21
  • 4
  • @SvyatoslavDanyliv I'm not sure that really helps to solve my problem of reusing lambda business logic on entities with different property names? FYI. I have rewritten my post slightly to make the actual problem clearer (hopefully). – RyanO Jul 26 '23 at 16:29

1 Answers1

0

You can silently correct Expression Tree by LINQKit (select appropriate EF Core version). Mark property UserId as Expandable and provide implementation function which returns Expression:

public partial class User : EntityTableData,  IUserAttributableEntity
{
    /// <summary>
    /// Make UserId translatable
    /// </summary>
    [NotMapped]
    [Expandable(nameof(UserIdIml))]
    public string UserId { get => Id; set { Id = value; } 

    static Expression<Func<User, string>> UserIdIml() => e => e.Id;

    ...
}

Before usage you have to activate LINQKit while building DbContextOptions:

builder
    .UseSqlServer(connectionString) // or any other provider
    .WithExpressionExpanding();     // enabling LINQKit extension
Svyatoslav Danyliv
  • 21,911
  • 3
  • 16
  • 32
  • Thanks, this could be what I'm looking for. I will take a look when I have a moment! – RyanO Aug 01 '23 at 14:15
  • This was looking promising but when I attempt to use UserId I receive the following error 'System.InvalidOperationException: No method 'UserIdIml' on type 'XXX.Models.User' is compatible with the supplied arguments.' ... Even though my User model contains the code suggested. `[NotMapped] [Expandable(nameof(UserIdIml))] public string UserId { get => Id; set => Id = value; } public static Expression> UserIdIml => e => e.Id;` – RyanO Aug 03 '23 at 09:02
  • My fault, missed brackets while defining `UserIdIml` method. Corrected. – Svyatoslav Danyliv Aug 03 '23 at 09:07
  • Ok corrected with brackets but I now receive this error... 'System.InvalidOperationException: The LINQ expression 'DbSet() .Where(u => u.UserId == __Authenticatable_DbUserId_0)' could not be translated. Additional information: Translation of member 'UserId' on entity type 'User' failed. This commonly occurs when the specified member is unmapped. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. – RyanO Aug 03 '23 at 09:14
  • FYI My program.cs setup looks slightly different to suggested: `builder.Services.AddDbContextFactory(options => options .UseSqlServer(connectionString) .WithExpressionExpanding() // enabling LINQKit extension , ServiceLifetime.Scoped);` – RyanO Aug 03 '23 at 09:16
  • `u.UserId` should be replaced with `u.Id`. It is what `ExpandableAttribue` is doing. Maybe there is problem with querying via interface... – Svyatoslav Danyliv Aug 03 '23 at 09:18
  • Yes it should. The line of code causing the exception isn't actually using an interface, it hasn't got that far. `public User? DbUser => DbContext.Users.FirstOrDefault(u => u.UserId == AuthenticatedId && u.Deleted == false);` – RyanO Aug 03 '23 at 09:29
  • Put breakpoint into `UserIdIml` body and check that it is even called. Also try `DbContext.Users.AsExpandable().FirstOrDefault(u => u.UserId == AuthenticatedId && u.Deleted == false)` maybe configuration is wrong. – Svyatoslav Danyliv Aug 03 '23 at 09:31
  • Ensure that you are using right LINQKit version. – Svyatoslav Danyliv Aug 03 '23 at 09:32
  • I can confirm it does hit the breakpoint of UserIdIml body. – RyanO Aug 03 '23 at 10:16
  • I have also tried `public User? DbUser => DbContext.Users.AsExpandable().FirstOrDefault(u => u.UserId == AuthenticatedId && u.Deleted == false);` which results in the same exception. – RyanO Aug 03 '23 at 10:19
  • My versions... – RyanO Aug 03 '23 at 10:22