1

I have two OData Controllers:

PersonsController
PersonsUnrestrictedController

The only way in which they will differ is that a couple of properties has to get their values from different columns in the persons table depending on the controller.

The PersonsController will send back a list of Persons where the persons givenname, familyname etc are alias names whereas PersonsUnrestrictedController will send back a list of Persons with the persons real names. All other properties will be exactly the same, including navigation properties and their relationships with other tables.

It is extremely important that PersonsController under no circumstances reveals a persons real name.

Is it possible to dynamically switch between:

[Column("AltGivenName")]
public string GivenName { get; set; }

and

[Column("GivenName")]
public string GivenName { get; set; }

depending on controller?

Or alternatively have two properties GivenName and AltGivenName and dynamically hide/reveal 1 of them depending on controller:

[DataMember(Name="GivenName")] //Either should this one be ignored
public string AltGivenName { get; set; }

public string GivenName { get; set; } //or this one, depending on controller

Or are there any other possible workarounds?

Edit: Added my code

Startup.cs

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<PersonContext>(options => { options.UseSqlServer(Configuration.GetConnectionString("MyConnection")); });
            services.AddOData();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapODataRoute("odata", "odata", GetEdmModel());
                endpoints.Select().Expand().MaxTop(null).Count();
            });
        }

        private static IEdmModel GetEdmModel()
        {
            var builder = new ODataConventionModelBuilder();
            var persons = builder.EntitySet<Person>("Persons");
            return builder.GetEdmModel();
        }
    }

PersonContext.cs

    public class PersonContext : DbContext
    {
        public DbSet<DbPerson> Persons { get; set; }

        public PersonContext(DbContextOptions<PersonContext> options) : base(options)
        {
        }
    }

DbPerson.cs

    [Table("Person")]
    public class DbPerson
    {
        [Key]
        public int Id { get; set; }

        public string GivenName { get; set; }

        public string AltGivenName { get; set; }
    }

Person.cs

    public class Person
    {
        [Key]
        public int Id { get; set; }

        public string GivenName { get; set; }
    }

MappingHelper.cs

    public static class MappingHelper
    {
        public static Person ToPerson(this DbPerson dbPerson)
        {
            return new Person
            {
                Id = dbPerson.Id,
                GivenName = dbPerson.GivenName,
            };
        }

        public static Person ToAnonymousPerson(this DbPerson dbPerson)
        {
            return new Person
            {
                Id = dbPerson.Id,
                GivenName = dbPerson.AltGivenName,
            };
        }
    }

PersonsController.cs

    public class PersonsController : ODataController
    {
        private readonly PersonContext _context;

        public PersonsController(PersonContext context)
        {
            _context = context;
        }

        [EnableQuery]
        public IActionResult Get()
        {
            return new ObjectResult(_context.Persons.Select(MappingHelper.ToPerson));
        }
    }

Running the following query takes 5-10 seconds http://localhost:4871/odata/persons?$top=10

If I instead change:

            return new ObjectResult(_context.Persons.Select(MappingHelper.ToPerson));

to

            return new ObjectResult(_context.Persons);

and change

var persons = builder.EntitySet<Person>("Persons"); 

to

var persons = builder.EntitySet<DbPerson>("Persons");

the same query takes 50-100 ms

There are about 150k persons in the person table.

Marty78
  • 87
  • 7
  • Please show some example urls between the two controllers, for instance are there two different routes you are expecting to use, or is there some other mechanism to determine if this is a restricted or unrestricted query? Your examples only have "/Persons" route. – Chris Schaller Mar 05 '21 at 00:03
  • Also can you show one or two other functions or actions that you would like each controller to offer, to help demonstrate the _dynamic_ nature of your requirement. – Chris Schaller Mar 05 '21 at 02:47
  • What operations do you want to support on the `PersonsController`? Why does it need to be an entirely separate controller? – Chris Schaller Mar 06 '21 at 12:48

3 Answers3

1

Configure PersonsUnrestrictedController to return the standard set of DB operations, this is effectively your internal DbPerson api, and to define PersonsController as a dedicated controller to serve access to a Data Transfer Object called Person.

You already have most of the elements defined, all we need to change is the controller implementation.

No changes to the following:

  • DbPerson.cs
  • Person.cs
  • PersonContext.cs

Define the two controllers in your EdmModel:

private static IEdmModel GetEdmModel()
{
    var builder = new ODataConventionModelBuilder();
    var persons = builder.EntitySet<Person>("Persons");
    var unrestricted = builder.EntitySet<DbPerson>("PersonsUnrestricted");
    return builder.GetEdmModel();
}

Rather than creating a Mapping method to map the objects, a simpler pattern would be to add a method into your controller to provide the base query that all operations in that controller should use.

In this way you could enforce common filter criteria, includes or sorting without having to declare the same query in each action. Its an easier pattern to maintain long term, you'll thank me when you have 20 actions or functions all with the same query that you need to refactor, or when you have to refactor a similar condition across multiple controllers.

Public Persons Controller:

public class PersonsController : ODataController
{
    private readonly PersonContext _context;

    public PersonsController(PersonContext context)
    {
        _context = context;
    }
    
    private IQueryable<Person> GetQuery() 
    {
        return from p in _context.Persons
               select new Person 
               { 
                   Id = p.Id,
                   GivenName = p.AltGivenName 
               };
    }

    [EnableQuery]
    public IActionResult Get()
    {
        return Ok(GetQuery());
    }

    [EnableQuery]
    public IActionResult Get(int key)
    {
        return Ok(GetQuery().Single(x => x.Id == key));
    }
}

Unrestricted Controller:

public class PersonsUnrestrictedController : ODataController
{
    private readonly PersonContext _context;

    public PersonsUnrestrictedController(PersonContext context)
    {
        _context = context;
    }

    private IQueryable<DbPerson> GetQuery() 
    {
        return _context.Persons;
    }

    [EnableQuery]
    public IActionResult Get()
    {
        return Ok(GetQuery());
    }

    [EnableQuery]
    public IActionResult Get(int key)
    {
        return Ok(GetQuery().Single(x => x.Id == key));
    }
}
Chris Schaller
  • 13,704
  • 3
  • 43
  • 81
  • OP has pointed out that this solution doesn't work in asp.net and EF6 and results in "the entity or complex type cannot be constructed in a linq to entities query" this is discussed many years ago on SO: https://stackoverflow.com/a/5325861/1690217 a work around would be to implement a view within the database schema, but that seems a little bit overkill to me. – Chris Schaller Mar 06 '21 at 13:34
1

The other answers here have focussed specifically on your request for 2 separate controllers mapped to the same table, but what it sounds like all you really need is a customised read-only feed from an OData Entity, that still has query support.

In OData this commonly implemented by defining a Function endpoint on the standard controller that returns a queryable set of DTOs.

No changes to the following:

  • DbPerson.cs
  • Person.cs
  • PersonContext.cs

In this solution however we will have a single PersonsController that will have the standard Get() endpoint and a Function View() endpoint.

private static IEdmModel GetEdmModel()
{
    var builder = new ODataConventionModelBuilder();
    var persons = builder.EntitySet<DbPerson>("Persons");
    persons.EntityType.Collection.Function("View").ReturnsCollection<Person>();
    return builder.GetEdmModel();
}

PersonsController.cs

public class PersonsController : ODataController
{
    private readonly PersonContext _context;

    public PersonsController(PersonContext context)
    {
        _context = context;
    }
    
    [HttpGet]
    [EnableQuery]
    public IActionResult Get()
    {
        return Ok(_context.Persons);
    }

    [HttpGet]
    [EnableQuery]
    public IActionResult View()
    {
        return Ok(from p in _context.Persons
                  select new Person 
                  { 
                      Id = p.Id,
                      GivenName = p.AltGivenName 
                  });
    } 

}

OP has specifically asked about asp.net-core however this response will work in both EF6 & asp.net and asp.net-core

Chris Schaller
  • 13,704
  • 3
  • 43
  • 81
  • Thank you very very much Chris. That is pretty much exactly what I want.The only thing different from what I would really want is the View-type of approach for both Persons and PersonsUnrestricted so then I would still need two controllers (since in practice I want to do some mapping on other properties/fields as well in both cases.) However my last question is really if the only way to get this kind of mapping functionallity is to use a Function? There is no way to do that directly in the Get method, is there? – Marty78 Mar 07 '21 at 08:12
  • Nevermind, I thought I had already tested this but apparently I must have made some mistake last time because when I tried it again now it worked. The only difference really is that I need to define a key in my business model when not using a function. Again, thank you very much for all your help. I have learned some new things that has really helped me. I have been struggling with this for quite some time. – Marty78 Mar 07 '21 at 08:20
0

In cases like this (as a matter of fact, as a general rule) I would suggest that you separate your database models from your business models and have some mapping logic in-between.

This way you can have two business models Person and AnonymousPerson and one database model DbPerson (call them what you will). You then implement some mapping logic to convert DbPerson to Person and DbPerson to AnonymousPerson and use the proper mapping logic based on the situation you're in.

As an example, check out this fiddle. For a little more details, consider we have the following database model and two business object models:

public class DbPerson
{
    public string GivenName { get; set; }
    public string FamilyName { get; set; }
    public string AltGivenName { get; set; }
}

public class Person
{
    public string GivenName { get; set; }
    public string FamilyName { get; set; }
}

public class AnonymousPerson
{
    public string Nickname { get; set; }
}

Then we need some logic to convert the database model into either of the two business objects. I'm using extension methods here, but you can also just do normal methods if you prefer:

public static class MappingHelper
{
    public static Person ToPerson(this DbPerson dbPerson)
    {
        return new Person
        {
            GivenName = dbPerson.GivenName,
            FamilyName = dbPerson.FamilyName
        };
    }
    
    
    public static AnonymousPerson ToAnonymousPerson(this DbPerson dbPerson)
    {
        return new AnonymousPerson
        {
            Nickname = dbPerson.AltGivenName
        };
    }
}

Now, to get the data in the right format, you simply use the mapping method you need (using an Entity Framework-like approach):

// From PersonsController (or a service beneath)
var persons = myDatabase.Persons.Select(MappingHelper.ToPerson);
// Same as                       Select(p => p.ToPerson())
// or                            Select(p => MappingHelper.ToPerson(p))


// And from PersonsUnrestrictedController
var anonPersons = myDatabase.Persons.Select(MappingHelper.ToAnonymousPerson);
Xerillio
  • 4,855
  • 1
  • 17
  • 28
  • Thank you very much for you answer. In case you do have a concrete example it would be much appreciated. – Marty78 Mar 02 '21 at 17:31
  • Just to add, I'm a bit worried mapping all the data will greatly reduce the performance of the service. – Marty78 Mar 02 '21 at 17:52
  • @Marty78 Example added. I hope it helps :) – Xerillio Mar 02 '21 at 17:54
  • @Marty78 I believe data mapping like this is a very common approach as it helps separate the business logic from the low-level data storage models. C# is very optimized when it comes to instantiating objects, so unless you have *extremely high* requirements to performance or need some *very heavy* mapping logic, you're not gonna notice any change with this. – Xerillio Mar 02 '21 at 17:57
  • Unfortunately that doesn't seem to work for me. I tried your approach (doing exactly as you wrote) just mapping a few of the properties and it already goes from 50-100 ms to 5-10 seconds. – Marty78 Mar 02 '21 at 19:13
  • It's like the entire table gets mapped and not just the persons I filter out. – Marty78 Mar 02 '21 at 19:44
  • @Marty78 I would need to see the code to tell what's wrong. Are you perhaps calling `ToList` before you do the filtering/query? There's no way this simple mapping alone will increase the time by a factor of 100, so something else must be at play. – Xerillio Mar 02 '21 at 19:48
  • @Marty78 ah, if I'm not mistaken you need to use [AsQueryable](https://learn.microsoft.com/en-us/dotnet/api/system.linq.queryable.asqueryable?view=net-5.0) as the OData library only seems to work on an `IQueryable`. So: `...Select(...).AsQueryable()`. I don't have an application to test it on, so if you can confirm that works, I'll update my answer with that info – Xerillio Mar 02 '21 at 22:39
  • Yeah, I actually did try that earlier (and double checked it now) but it does not make any difference unfortunately. The query works with or without .AsQueryable() and is equally slow. – Marty78 Mar 02 '21 at 23:01
  • My guess is: If I remove the mapping and I would want to filter on, let's say, $filter=GivenName eq 'John' then the query could go straight to the db just grabbing the persons that has John as givenname. Now when I have the mapping instead, in order for the $filter=GivenName eq 'John' to work the entire table has to be mapped which makes the query get all the persons after which OData runs the query against my class rather than against the db. And mapping all those people and running the query against that data takes much longer than just doing a SELECT * FROM Person WHERE GivenName = 'John'. – Marty78 Mar 02 '21 at 23:28
  • @Marty78 It could be, however, I would have expected the OData framework to be able to make proper use of `IQueryable` which - through EF Core as an example - can handle building the query before reaching the database to avoid a full table scan. However, I made a small test application and haven't been able to figure out a way to avoid the full table scan. Perhaps it's some missing setup or OData just doesn't handle that which in that case is a big flaw. It's good practice to model your data to present your intent rather than let the context decide how your data should be interpreted. – Xerillio Mar 03 '21 at 21:11
  • @Xerillio OData framework absolutely _make proper use of IQueryable_ but it up to you to make a proper _IQueryable_ expression for it to pass through! Your mapping helper is causing the bottle neck, because your implementation is not an _expression_, it is an _operation_ and linq can't easily interpret the contents of your mapping, instead you should look at using _projections_. – Chris Schaller Mar 04 '21 at 23:31
  • @Xerillio I should have clarified, this is a known issue in the .Net Core implementation of Linq to Entities, you need to use query syntax not fluent style. Refactoring the projection into an separate method in .Net FX works really well but in Core it cannot determine the correct expression tree, so at the mapping function _junction_ EF realizes the whole set into memory so it can evaluate the projection locally. – Chris Schaller Mar 05 '21 at 05:15
  • @ChrisSchaller I see, so it's an EF gotcha. I did also expect I was missing something as I expected OData would have no problems with it. So EF doesn't turn a mapping function like that into a select query - good to know. – Xerillio Mar 05 '21 at 17:38
  • Its a combination of EF.Core and OData.Core. this all works fine in .Net FX. Some features that the OData team depended on in EF didn't come across to .Core. there's an open issue on it in Github somewhere. I'll post it here if I find it. – Chris Schaller Mar 05 '21 at 22:53
  • Some light reading: https://github.com/OData/WebApi/issues/2079 – Chris Schaller Mar 05 '21 at 22:59
  • @ChrisSchaller Chris, would you be willing to show me an example how to do this in regular .NET Framework using EF6? And if it is possible also an example how to project the query result into a business model as well that would work together with the oData query - instead of OData using the database model directly (Without it resulting in a database full scan.)? Your example works with Core but doing the same in .NET Framework results in "the entity or complex type cannot be constructed in a linq to entities query" – Marty78 Mar 06 '21 at 09:38
  • I see... in EF6, Projections will work for custom function end points, but not with the main entity that is mapped to the controller. That requires a different solution, I'll see if I can post it separately to this, for some background: https://stackoverflow.com/questions/5325797/the-entity-cannot-be-constructed-in-a-linq-to-entities-query – Chris Schaller Mar 06 '21 at 12:44