Using Web API and OData, I have a service which exposes Data Transfer Objects instead of the Entity Framework entities.
I use AutoMapper to transform the EF Entities into their DTO counter parts using ProjectTo():
public class SalesOrdersController : ODataController
{
private DbContext _DbContext;
public SalesOrdersController(DbContext context)
{
_DbContext = context;
}
[EnableQuery]
public IQueryable<SalesOrderDto> Get(ODataQueryOptions<SalesOrderDto> queryOptions)
{
return _DbContext.SalesOrders.ProjectTo<SalesOrderDto>(AutoMapperConfig.Config);
}
[EnableQuery]
public IQueryable<SalesOrderDto> Get([FromODataUri] string key, ODataQueryOptions<SalesOrderDto> queryOptions)
{
return _DbContext.SalesOrders.Where(so => so.SalesOrderNumber == key)
.ProjectTo<SalesOrderDto>(AutoMapperConfig.Config);
}
}
AutoMapper (V4.2.1) is configured as follows, note the ExplicitExpansion() which prevents serialisation auto expanding navigation properties when they are not requested:
cfg.CreateMap<SalesOrderHeader, SalesOrderDto>()
.ForMember(dest => dest.SalesOrderLines, opt => opt.ExplicitExpansion());
cfg.CreateMap<SalesOrderLine, SalesOrderLineDto>()
.ForMember(dest => dest.MasterStockRecord, opt => opt.ExplicitExpansion())
.ForMember(dest => dest.SalesOrderHeader, opt => opt.ExplicitExpansion());
ExplicitExpansion() then creates a new problem where the following request throws an error:
/odatademo/SalesOrders('123456')?$expand=SalesOrderLines
The query specified in the URI is not valid. The specified type member 'SalesOrderLines' is not supported in LINQ to Entities
The navigation property SalesOrderLines is unknown to EF so this error is pretty much what I expected to happen. The question is, how do I handle this type of request?
The ProjectTo() method does have an overload that allows me to pass in an array of properties that require expansion, I found & modified the extension method ToNavigationPropertyArray to try and parse the request into a string array:
[EnableQuery]
public IQueryable<SalesOrderDto> Get([FromODataUri] string key, ODataQueryOptions<SalesOrderDto> queryOptions)
{
return _DbContext.SalesOrders.Where(so => so.SalesOrderNumber == key)
.ProjectTo<SalesOrderDto>(AutoMapperConfig.Config, null, queryOptions.ToNavigationPropertyArray());
}
public static string[] ToNavigationPropertyArray(this ODataQueryOptions source)
{
if (source == null) { return new string[]{}; }
var expandProperties = string.IsNullOrWhiteSpace(source.SelectExpand?.RawExpand) ? new List<string>().ToArray() : source.SelectExpand.RawExpand.Split(',');
for (var expandIndex = 0; expandIndex < expandProperties.Length; expandIndex++)
{
// Need to transform the odata syntax for expanding properties to something EF will understand:
// OData may pass something in this form: "SalesOrderLines($expand=MasterStockRecord)";
// But EF wants it like this: "SalesOrderLines.MasterStockRecord";
expandProperties[expandIndex] = expandProperties[expandIndex].Replace(" ", "");
expandProperties[expandIndex] = expandProperties[expandIndex].Replace("($expand=", ".");
expandProperties[expandIndex] = expandProperties[expandIndex].Replace(")", "");
}
var selectProperties = source.SelectExpand == null || string.IsNullOrWhiteSpace(source.SelectExpand.RawSelect) ? new List<string>().ToArray() : source.SelectExpand.RawSelect.Split(',');
//Now do the same for Select (incomplete)
var propertiesToExpand = expandProperties.Union(selectProperties).ToArray();
return propertiesToExpand;
}
This works for expand, so now I can handle a request like the following:
/odatademo/SalesOrders('123456')?$expand=SalesOrderLines
or a more complicated request like:
/odatademo/SalesOrders('123456')?$expand=SalesOrderLines($expand=MasterStockRecord)
However, more complicated request that try to combine $select with $expand will fail:
/odatademo/SalesOrders('123456')?$expand=SalesOrderLines($select=OrderQuantity)
Sequence contains no elements
So, the question is: am I approaching this the right way?
It feels very smelly that I would have to write something to parse and transform the ODataQueryOptions into something EF can understand.
It seems this is a rather popular topic:
odata-expand-dtos-and-entity-framework
how-to-specify-the-shape-of-results-with-webapi2-odata-with-expand
web-api-queryable-how-to-apply-automapper
how-do-i-map-an-odata-query-against-a-dto-to-another-entity
While most of these suggest using ProjectTo, none seem to address serialisation auto expanding properties, or how to handle expansion if ExplictExpansion has been configured.
Classes and Config below:
Entity Framework (V6.1.3) entities:
public class SalesOrderHeader
{
public string SalesOrderNumber { get; set; }
public string Alpha { get; set; }
public string Customer { get; set; }
public string Status { get; set; }
public virtual ICollection<SalesOrderLine> SalesOrderLines { get; set; }
}
public class SalesOrderLine
{
public string SalesOrderNumber { get; set; }
public string OrderLineNumber { get; set; }
public string Product { get; set; }
public string Description { get; set; }
public decimal OrderQuantity { get; set; }
public virtual SalesOrderHeader SalesOrderHeader { get; set; }
public virtual MasterStockRecord MasterStockRecord { get; set; }
}
public class MasterStockRecord
{
public string ProductCode { get; set; }
public string Description { get; set; }
public decimal Quantity { get; set; }
}
OData (V6.13.0) Data Transfer Objects:
public class SalesOrderDto
{
[Key]
public string SalesOrderNumber { get; set; }
public string Customer { get; set; }
public string Status { get; set; }
public virtual ICollection<SalesOrderLineDto> SalesOrderLines { get; set; }
}
public class SalesOrderLineDto
{
[Key]
[ForeignKey("SalesOrderHeader")]
public string SalesOrderNumber { get; set; }
[Key]
public string OrderLineNumber { get; set; }
public string LineType { get; set; }
public string Product { get; set; }
public string Description { get; set; }
public decimal OrderQuantity { get; set; }
public virtual SalesOrderDto SalesOrderHeader { get; set; }
public virtual StockDto MasterStockRecord { get; set; }
}
public class StockDto
{
[Key]
public string StockCode { get; set; }
public string Description { get; set; }
public decimal Quantity { get; set; }
}
OData Config:
var builder = new ODataConventionModelBuilder();
builder.EntitySet<StockDto>("Stock");
builder.EntitySet<SalesOrderDto>("SalesOrders");
builder.EntitySet<SalesOrderLineDto>("SalesOrderLines");
I have created an Automapper explicit navigation expansion utility function that should work with N-deph expands. Posting it here since it might help someone.
public List<string> ProcessExpands(IEnumerable<SelectItem> items, string parentNavPath="")
{
var expandedPropsList = new List<String>();
if (items == null) return expandedPropsList;
foreach (var selectItem in items)
{
if (selectItem is ExpandedNavigationSelectItem)
{
var expandItem = selectItem as ExpandedNavigationSelectItem;
var navProperty = expandItem.PathToNavigationProperty?.FirstSegment?.Identifier;
expandedPropsList.Add($"{parentNavPath}{navProperty}");
//go recursively to subproperties
var subExpandList = ProcessExpands(expandItem?.SelectAndExpand?.SelectedItems, $"{parentNavPath}{navProperty}.");
expandedPropsList = expandedPropsList.Concat(subExpandList).ToList();
}
}
return expandedPropsList;
}
You can call it with :
var navExp = ProcessExpands(options?.SelectExpand?.SelectExpandClause?.SelectedItems)
it will return a list with ["Parent" ,"Parent.Child"]
I never really managed to work this one out. The ToNavigationPropertyArray() extension method helps a little, but does not handle infinite depth navigation.
The real solution is to create Actions or Functions to allow clients to request data requiring a more complicated query.
The other alternative is to make multiple smaller/simple calls then aggregate the data on the client, but this isn't really ideal.
When you want to mark something for explicit expansion in AutoMapper, you need to also opt-back-in when calling ProjectTo<>().
// map
cfg.CreateMap<SalesOrderHeader, SalesOrderDto>()
.ForMember(dest => dest.SalesOrderLines, opt => opt.ExplicitExpansion());
// updated controller
[EnableQuery]
public IQueryable<SalesOrderDto> Get()
{
return _dbContext.SalesOrders
.ProjectTo<SalesOrderDto>(
AutoMapperConfig.Config,
so => so.SalesOrderLines,
// ... additional opt-ins
);
}
While the AutoMapper wiki does state this, the example is perhaps a little misleading by not including the paired ExplicitExpansion() call.
To control which members are expanded during projection, set ExplicitExpansion in the configuration and then pass in the members you want to explicitly expand:
Related
I am in need of returning a list of entities (Proposals) from an odata controller, but it needs to also return another Property entity together (Summary) with it that is populated from the Summarize method. This other entity is not persisted nor does existing in the dbcontext (builder.Ignore(p => p.Summary)).
The issue is that it can't be a complextype for my use, so I tried to add it as a EntitySet on the buiilder and Automatic expand on the parent (as I can't add it to the query the expand), but then every time I try to query it, the following exception raises.
System.Runtime.Serialization.SerializationException: Cannot serialize a null 'ResourceSet'.
at Microsoft.AspNet.OData.Formatter.Serialization.ODataResourceSetSerializer.WriteObjectInline(Object graph, IEdmTypeReference expectedType, ODataWriter writer, ODataSerializerContext writeContext) [...]
I wonder if this happens because of the entity not existing to the context.
So is there a way to make this work? to not have this other entity persisted but return it together with the odata query for its parent?
Results:
{"#odata.context":"http://localhost:57450/OData/$metadata#Proposal","value":[{"ProposalID":"e91cacfc-f345-4617-bd54-b1f440e4fd65","CustomerNumber":null,"Description":"","ServiceType":"LeaseReturn","Currency":null,"PartnerName":null,"QuoteDate":"0001-01-01T00:00:00-02:00","CreatedOn":"2018-08-06T16:27:52.8169404-03:00","CreatedBy":"Admin","UpdatedOn":null,"UpdatedBy":null,"ProposalServiceFees":[]},{"ProposalID":"28ddfea6-2ac9-4898-b72f-d5e284d5072f","CustomerNumber":"2","Description":"a","ServiceType":"ResaleAndRecycle","Currency":null,"PartnerName":null,"QuoteDate":"0001-01-01T00:00:00-02:00","CreatedOn":"2018-08-15T15:11:07.531755-03:00","CreatedBy":"Admin","UpdatedOn":null,"UpdatedBy":null,"ProposalServiceFees":[]}]}
Expected:
{"#odata.context":"http://localhost:57450/OData/$metadata#Proposal","value":[{"ProposalID":"e91cacfc-f345-4617-bd54-b1f440e4fd65","CustomerNumber":null,"Description":"","ServiceType":"LeaseReturn","Currency":null,"PartnerName":null,"QuoteDate":"0001-01-01T00:00:00-02:00","CreatedOn":"2018-08-06T16:27:52.8169404-03:00","CreatedBy":"Admin","UpdatedOn":null,"UpdatedBy":null,"Summary":{"SummaryId":"e91cacfc-f345-4617-bd54-b1f440e4fd65","Items":[{"Type":"workstation","Price":0.00}],"Fees":[]},"ProposalServiceFees":[]},{"ProposalID":"28ddfea6-2ac9-4898-b72f-d5e284d5072f","CustomerNumber":"2","Description":"a","ServiceType":"ResaleAndRecycle","Currency":null,"PartnerName":null,"QuoteDate":"0001-01-01T00:00:00-02:00","CreatedOn":"2018-08-15T15:11:07.531755-03:00","CreatedBy":"Admin","UpdatedOn":null,"UpdatedBy":null,"Summary":{"SummaryId":"28ddfea6-2ac9-4898-b72f-d5e284d5072f","Items":[{"Type":"strings","Price":0.00}],"Fees":[]},"ProposalServiceFees":[]}]}
Entities:
public class Proposal : EntityBase {
public Guid ProposalID { get; set; }
public string CustomerNumber { get; set; }
public string Description { get; set; }
public string ServiceType { get; set; }
public string Currency { get; set; }
public Summary Summary { get; set; }
public string PartnerName { get; set; }
public DateTime QuoteDate { get; set; }
[...]
public void Summarize()
=> Summary = new Summary(Assets, ProposalServiceFees.Select(f=>f.ServiceFee), ProposalID);
}
public class Summary
{
public Guid SummaryId { get; set; }
public IEnumerable<SummaryItem> Items { get; private set; }
public IEnumerable<SummaryItem> Fees { get; private set; }
internal Summary(IEnumerable<Asset> assets, IEnumerable<ServiceFee> fees, Guid id)
{
SummaryId = id; //dummy for test
Items = assets
.GroupBy(asset => asset.ProductType, GroupIntoSummaryItem)
.ToList();
Fees = fees
.Select(f=> FeeToSummaryItem(f.ApplyServiceFee(assets.Count())))
.ToList();
}
[...]
}
Controller:
[EnableQuery]
[ODataRoute()]
public IQueryable<Proposal.Domain.Entities.Proposal> Get(ODataQueryOptions opts)
{
opts.Validate(settings);
IQueryable results = opts.ApplyTo(_context.Proposal.Include(x => x.Assets)).AsQueryable();
var values = (results as IQueryable<Proposal.Domain.Entities.Proposal>)?.ToList();
values?.ForEach(x => x.Summarize());
return values?.AsQueryable();
}
OdataConventionModelBuilder:
EntitySet<Summary>(nameof(Summary))
.EntityType
.HasKey(p => p.SummaryId)
.Filter()
.Count()
.Expand()
.OrderBy()
.Page()
.Select();
EntitySet<Domain.Entities.Proposal>(nameof(Domain.Entities.Proposal))
.EntityType
.HasKey(p => p.ProposalID)
.Expand(SelectExpandType.Automatic, nameof(Summary))
.Filter()
.Count()
.Expand()
.OrderBy()
.Page()
.Select();
This is most likely due to your model structure not matching your DB structure. This happens quite often when you cut/paste and forget to rename an entity.
As I'm developping an asp net core + ef core 2.0 with localized objects in my model, I adapted the solution provided in the following link to localize my objects link.
I'm now trying to find a clean way to update my collection of translation when updated object are received in the controller.
For the moment I have a step model class defined this way :
public class Step
{
//Native properties
public Guid ID { get; set; }
public string Name { get; set; }
public int Order { get; set; }
public string ScriptBlock { get; set; }
//Parent Step Navigation property
public Nullable<Guid> ParentStepID { get; set; }
public virtual Step ParentStep { get; set; }
//Collection of sub steps
public virtual ICollection<Step> SubSteps { get; set; }
//MUI Properties
public TranslationCollection<StepTranslation> Translations { get; set; }
public string Description { get; set; }
//{
// get { return Translations[CultureInfo.CurrentCulture].Description; }
// set { Translations[CultureInfo.CurrentCulture].Description = value; }
//}
public Step()
{
//ID = Guid.NewGuid();
Translations = new TranslationCollection<StepTranslation>();
}
}
public class StepTranslation : Translation<StepTranslation>
{
public Guid StepTranslationId { get; set; }
public string Description { get; set; }
public StepTranslation()
{
StepTranslationId = Guid.NewGuid();
}
}
Translation and translationCollection are the same as in the link
public class TranslationCollection<T> : Collection<T> where T : Translation<T>, new()
{
public T this[CultureInfo culture]
{
// indexer
}
public T this[string culture]
{
//indexer
}
public bool HasCulture(string culture)
{
return this.Any(x => x.CultureName == culture);
}
public bool HasCulture(CultureInfo culture)
{
return this.Any(x => x.CultureName == culture.Name);
}
}
public abstract class Translation<T> where T : Translation<T>, new()
{
public Guid Id { get; set; }
public string CultureName { get; set; }
protected Translation()
{
Id = Guid.NewGuid();
}
public bool HasProperty(string name)
{
return this.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Any(p => p.Name == name);
}
}
My issue in this sample is how to deal correctly with the PUT method and the Description property of my step controller. When it receive a Step object to update (which is done through a native c# client) only the string Description property of Step might have been created/updated/unchanged. So I have to update/create/do Nothing on the Description of the translation in the correct culture.
My first guess is to add in the TranslationCollection class a method in which I could pass the culture, the name of the property to update or not (Description in this case) and the value of the Description.
But as the TranslationCollection is a collection of abstract objects I don't even if this is a good idea and if it's possible.
If someone would have any advice on it (hoping I was clear enough) it would be great !
Finally answered my own question, and it was quite simple.
Just had to use the indexer like :
myobject.Translations[userLang].Name = value;
I'm trying to use the feature documented here :
https://github.com/ServiceStack/ServiceStack.OrmLite#custom-sql-customizations
This is how I'm using it:
var q = Db.From<MemberAccess>().LeftJoin<Member>();
return Db.Select<MemberResponse>(q);
Response object:
public class MemberResponse
{
public Guid Id { get; set; }
public string MemberFirstName { get; set; }
public string MemberLastName { get; set; }
public string MemberEmail { get; set; }
[Default(OrmLiteVariables.SystemUtc)]
public string AccessedOn { get; set; }
[CustomSelect("CONCAT(LEFT(Member.FirstName, 1),LEFT(Member.LastName,1))")]
public string MemberInitial { get; set; }
}
It seems like whatever I put in CustomSelect doesn't get used. Maybe, I'm not using this correctly? Also, the Default attribute doesn't work either.I tried that as it was an example from the doco.
Any idea will be appreciated.
Thanks in advance.
The [CustomSelect] only applies to the source table. Selecting the results in a custom type is used to map the returned resultset on the MemberResponse type, it doesn't have any effect on the query that gets executed.
Likewise with [Default(OrmLiteVariables.SystemUtc)] that's used to define the default value when creating the table which is only used when it creates the Column definition, so it's only useful on the source Table Type.
Both these attributes should only be added on the source MemberAccess to have any effect, which your mapped MemberResponse can access without any attributes, e.g:
public class MemberResponse
{
public Guid Id { get; set; }
public string MemberFirstName { get; set; }
public string MemberLastName { get; set; }
public string MemberEmail { get; set; }
public string AccessedOn { get; set; }
public string MemberInitial { get; set; }
}
Sql.Custom() API
The new Sql.Custom() API added in v4.5.5 that's available on MyGet will let you select a custom SQL Fragment, e.g:
var q = Db.From<MemberAccess>().LeftJoin<Member>()
.Select<MemberAccess,Member>((a,m) => new {
Id = a.Id,
MemberFirstName = m.FirstName,
MemberLastName = m.LastName,
MemberEmail = m.Email,
MemberInitial = Sql.Custom("CONCAT(LEFT(Member.FirstName,1),LEFT(Member.LastName,1))")
});
return Db.Select<MemberResponse>(q);
Let me preface this question with, I am VERY new to ASP.NET Core/EF Core.
My model look like this:
namespace MyProject.Models
{
public class DeviceContext : DbContext
{
public DeviceContext(DbContextOptions<DeviceContext> options) : base(options) { }
public DbSet<Device> Devices { get; set; }
public DbSet<DeviceLocation> DeviceLocations { get; set; }
}
public class Device
{
public int Id { get; set; }
public string DeviceName { get; set; }
public string ServerName { get; set; }
public string MacAddress { get; set; }
public string LastUpdate { get; set; }
public string WiredIPAddress { get; set; }
public string WirelessIPAddress { get; set; }
public DeviceLocation DeviceLocation { get; set; }
}
public class DeviceLocation
{
public int Id { get; set; }
public string Location { get; set; }
public virtual ICollection<Device> Devices { get; set; }
}
}
I would like to be able to fetch a specific Device based on DeviceName, but I would also like to fetch ALL the devices in a particular Location.
I think the following would work for the first question:
var _Devices = DeviceContext.Devices.FirstOrDefault(d => d.DeviceName == "BLA");
I am just having a hard time getting the second query to run. Ideally, the output would be rendered to JSON to be consumed by an API. I would like the output to look something like this:
{
"Locations": {
"NYC": ["ABC", "123"],
"Boston": ["DEF", "456"],
"Chicago": ["GHI", "789"]
}
}
UPDATE
If I use the following code, it give me the following error:
Code:
// Grouping by ProfileName
var devices = DeviceContext.DeviceLocations.Include(n => n.Device).ToList();
var result = new { success = true, message = "Successfully fetched Devices", data = devices };
return JsonConvert.SerializeObject(result);
Error:
Additional information: Self referencing loop detected for property 'DeviceLocation' with type 'Project.Models.DeviceLocation'. Path 'data[0].Device[0]'.
You can try as shown below.
Note : Use Eager Loading with Include.
using System.Data.Entity;
var devicesList = DeviceContext.DeviceLocations.Where(d=>d.Location = "Your-Location-Name")
.Include(p => p.Devices)
.ToList();
Update :
var devicesList = DeviceContext.DeviceLocations
.Include(p => p.Devices)
.ToList();
I am trying to query RavenDB on a Datetime which is being offset by a entry in a collection. As shown below, I have an AppointmentReminder object which contains many AppointmentReminderJobs. I'd like to query for AppointmentReminders where the AppointmentReminderJob is due to run.
My models are as follows:
public class AppointmentReminder
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Phone { get; set; }
public DateTime AppointmentDateTime { get; set; }
public ReminderStatus ReminderStatus { get; set; }
public List<AppointmentReminderJob> AppointmentReminderJobs { get; set; }
}
public class AppointmentReminderJob
{
public JobStatus JobStatus { get; set; }
public int DaysPrior { get; set; }
}
My controller and attempt to retrieve a list of AppointmentReminders which have current jobs to run (I know this Where clause isn't complete, but I've tried to simplify it with no luck):
public ActionResult GetJobsQueuedListCurrent()
{
var jobsqueuedlist = RavenSession.Query<AppointmentReminder>()
.Where(appointmentreminder => appointmentreminder.AppointmentReminderJobs.Any(x => appointmentreminder.AppointmentDateTime < DateTime.Now.AddDays(x.DaysPrior)))
.OrderBy(appointmentreminder => appointmentreminder.AppointmentDateTime)
.Take(20)
.ToList();
return View("List", jobsqueuedlist);
}
Calling the above yields a response of:
variable 'x' of type 'ProjectName.Models.AppointmentReminderJob' referenced from scope '', but it is not defined
I am trying to set up an index like so:
public class JobsQueuedListCurrent : AbstractIndexCreationTask<AppointmentReminder, JobsQueuedListCurrent.IndexResult>
{
public class IndexResult
{
public int Id { get; set; }
public DateTime JobDateTime { get; set; }
}
public JobsQueuedListCurrent()
{
Map = appointmentreminders => from appointmentreminder in appointmentreminders
from job in appointmentreminder.AppointmentReminderJobs
select new
{
Id = appointmentreminder.Id,
JobDateTime = appointmentreminder.AppointmentDateTime.AddDays(job.DaysPrior)
};
Store(x => x.Id, FieldStorage.Yes);
Store(x => x.JobDateTime, FieldStorage.Yes);
}
}
Now, I'm querying and getting expected results using:
var jobsqueuedlist = RavenSession.Query<JobsQueuedListCurrent.IndexResult, JobsQueuedListCurrent>()
.Where(x=>x.JobDateTime >= DateTime.Now)
.As<AppointmentReminder>()
.Take(20)
.ToList();
return View("List", jobsqueuedlist);
My last question regarding this would be, my map/index can definitely result in multiple entries of the same document id (appointmentreminder), but my resulting list contains only 1 instance of the document. I'm happy with the way that works, I'm just not sure if I should be performing a reduce or doing something else in my code or just let Raven handle it like it seems like it is doing?
You cannot create such a query. This would require RavenDB to perform computation during query, and that is not allowed.
RavenDB only allows queries on the data in the index.
What you can do it setup the computation in the index, and then query on that.