This project works with trucks and trailers - you know, over-the-road trucks and trailers. It is a MAUI .NET 8.0 project that runs to an Android device. The solution has an ASP.NET Core 8.0.3 Web API web service for posting to the database.
SUMMARY: my app needs to submit a truck to the database, then submit that truck's trailer. The problem is that I cannot successfully submit the truck's matching trailer. It seems that EF Core 8.0 handles foreign keys differently than older versions.
To create the model classes in the models project (gaModels
), I run a scaffolding command (so, reverse engineering) from the Package Manager Console window in Visual Studio. The questions I have found here on Stackoverflow use code-first.
Here is the scaffolding command:
Scaffold-DbContext "my Db connection string"
Microsoft.EntityFrameworkCore.SqlServer
-OutputDir wjbeitlerDb
-Tables "MobileUser", "LoggedInfo", "guardsTruck", "guardsTrailer"
-Force -Project "gaModels"
Tables
guardsTruck
guardsTrailer
Here is a screenshot of the truck table in SSMS:
Here is a screenshot of the trailer table table in SSMS:
I created a foreign key in the trailers table:
ALTER TABLE [dbo].[guardsTrailer] WITH NOCHECK
ADD CONSTRAINT [FK_guardsTrailer_guardsTruck]
FOREIGN KEY([TruckId])
REFERENCES [dbo].[guardsTruck] ([GuardsTruckId])
GO
ALTER TABLE [dbo].[guardsTrailer] NOCHECK CONSTRAINT [FK_guardsTrailer_guardsTruck]
GO
My plan was to:
Trailer.TruckId
However, EF Core 8.0's scaffolding command added an entire truck object to the trailer class (see the bottom of this code block):
public partial class GuardsTrailer
{
public int GuardsTrailerId { get; set; }
public int TruckId { get; set; }
public int Shipper { get; set; }
public string? BolNumber { get; set; }
public string? SealNumber { get; set; }
public int? RefferTemp { get; set; }
public string? TrailerNumber { get; set; }
public string? State { get; set; }
public string? LicensePlate { get; set; }
public string? ActualSealNumber { get; set; }
public int? SealCondition { get; set; }
public DateTime? TimeOut { get; set; }
public string? Comments { get; set; }
public string? PhoneNumber { get; set; }
public virtual GuardsTruck Truck { get; set; } = null!;
}
No matter what I put in that Truck
property, the submit to the database does not work.
I have tried:
TruckId = 0
Truck
in the database with a new ID
AND successfully creates a Trailer
with the NEW TruckID
Trailer
with null for the Truck
object, setting the TruckId to the value returned when I POSTed the truck.
Trailer
tableTrailer
with the RETURNED Truck
as the Trailer.Truck
object
Cannot send a Truck that already has a TruckId value
Here is the trailer entity that the scaffolding command creates in the DbContext
:
modelBuilder.Entity<GuardsTrailer>(entity =>
{
entity.ToTable("guardsTrailer");
entity.Property(e => e.GuardsTrailerId).HasColumnName("guardsTrailerId");
entity.Property(e => e.ActualSealNumber)
.HasMaxLength(20)
.IsFixedLength();
entity.Property(e => e.BolNumber)
.HasMaxLength(20)
.IsFixedLength();
entity.Property(e => e.Comments)
.HasMaxLength(50)
.IsFixedLength();
entity.Property(e => e.LicensePlate)
.HasMaxLength(20)
.IsFixedLength();
entity.Property(e => e.PhoneNumber)
.HasMaxLength(15)
.IsFixedLength();
entity.Property(e => e.SealNumber)
.HasMaxLength(20)
.IsFixedLength();
entity.Property(e => e.State)
.HasMaxLength(2)
.IsFixedLength();
entity.Property(e => e.TimeOut).HasColumnType("datetime");
entity.Property(e => e.TrailerNumber)
.HasMaxLength(20)
.IsFixedLength();
entity.HasOne(d => d.Truck).WithMany(p => p.GuardsTrailers)
.HasForeignKey(d => d.TruckId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("FK_guardsTrailer_guardsTruck");
});
A weird thing ^ there is that there is no HasKey
there like maybe:
entity.HasKey(e => e.GuardsTrailerId).HasName("PK__Name");
Likewise, the truck entity in the DbContext
does not have a HasKey
, AND the primary key, GuardsTruckId
, is not even in the entity:
modelBuilder.Entity<GuardsTruck>(entity =>
{
entity.ToTable("guardsTruck");
entity.Property(e => e.DriversFirstName)
.HasMaxLength(15)
.IsFixedLength();
entity.Property(e => e.DriversLastName)
.HasMaxLength(15)
.IsFixedLength();
entity.Property(e => e.LicensePlate)
.HasMaxLength(15)
.IsFixedLength();
entity.Property(e => e.LicensePlateState)
.HasMaxLength(2)
.IsFixedLength();
entity.Property(e => e.Shipper)
.HasMaxLength(15)
.IsFixedLength();
entity.Property(e => e.TimeOfEntry).HasColumnType("datetime");
entity.Property(e => e.TimeOfExit).HasColumnType("datetime");
});
How can I submit the truck then submit the matching trailer?
You will run into issues passing entities around in client-server environments like MAUI or MVC/Web API because with EF, references are everything in terms of importance. When you pass entities around you aren't passing tracked references, behind the scenes MVC/Web API will be instantiating new instances of entity classes, which when given to a DbContext, will be treated as new rows. This leads to exceptions or duplicate data inserts.
Inserting data that has no associations is typically no problem. Such as when you insert a new Truck. Inserting data that does have an association needs a bit more care and attention, such as inserting a Trailer since a Trailer is associated to a Truck.
Say we have a method that accepts a trailer we want to insert:
public Task<ActionResult> CreateTrailer(Trailer trailer)
... and that Trailer includes a Truck navigation property. When the Trailer (and associated Truck) were populated from a web request we cannot simply go:
_context.Trailers.Add(trailer);
The reason is that this will see a new reference to a Truck and try to insert that as well. Problem being, that truck already exists in the DB. First we have to handle the association. We could call _context.Attach(trailer.Truck);
first, and that would work some/most of the time, but depending on code that might have attached or loaded Trucks etc. that code could fail. The safest way to handle it would be:
public Task<ActionResult> CreateTrailer(Trailer trailer)
{
ArgumentNullException.ThrowIfNull(trailer, nameof(trailer));
var existingTruck = _context.Trucks.Local.FirstOrDefault(x => x.TruckId == trailer.Truck.TruckId);
if (existingTruck != null)
trailer.Truck = existingTruck;
else
_context.Attach(truck);
_context.Trailers.Add(trailer);
await _context.SaveChangesAsync();
return View(trailer);
}
Here we check the tracking cache (Trucks.Local). This doesn't hit the database, but if we find the context is tracking that truck we replace the trailer.Truck reference to the tracked Truck. If we aren't tracking the truck then we can Attach
it. Once that's done we can insert the trailer and it should go through without an error since either way it will treat the Truck as "this is an existing row".
When updating data we need to do the same checks, and I strongly recommend avoiding the DbContext
Update()
method. If we are updating a Trailer and some of its data can change, including getting associated to a new truck, do not simply try to use the Update()
method on the trailer. Again, associations will mess things up, and Update will overwrite everything about the entity, opening the door for tampering. Instead:
public Task<ActionResult> UpdateTrailer(Trailer trailer)
{
ArgumentNullException.ThrowIfNull(trailer, nameof(trailer));
var existingTrailer = await _context.Trailers
.Include(x => x.Truck)
.SingleAsync(x => x.TrailerId == trailer.TrailerId);
// Copy fields over, only what you expect/allow to be changed.
existingTrailer.TrailerNumber = trailer.TrailerNumber;
// ...
if (trailer.Truck.TruckId != existingTrailer.Truck.TruckId)
{
var existingTruck = await _context.Trucks
.SingleAsync(x => x.TruckId == trailer.Truck.TruckId);
existingTrailer.Truck = existingTruck;
}
await _context.SaveChangesAsync();
return View(existingTrailer);
}
The above code first fetches the existing trailer and truck from the database. If a record does not exist, it will throw. (Cannot update what is not there) We copy across any values we allow to be changed, and this will result in an UPDATE
SQL statement only for values that actually changed, and only if any actually changed. We don't need to check for changes, EF's change tracking does that automatically. We then check to see if the Truck reference changed, if so we fetch the new truck from the database (again asserts that the truck actually exists) and update the tracked trailer reference to the new truck before saving the changes.
Being explicit and working with loaded data, rather than trying to use the Update()
method handles associations. For raw speed we can do away with assertions and fetching the associated entities, but we have to remove association navigation properties first.
For instance if we want to insert a trailer for a truck and the trailer has a TruckId FK as well as a Truck navigation property, we have to remove any Truck navigation property before we attach or Update
the entity:
public Task<ActionResult> InsertTrailer(Trailer trailer)
{
ArgumentNullException.ThrowIfNull(trailer, nameof(trailer));
var truck = trailer.Truck;
trailer.Truck = null; // we will rely on trailer.TruckId
_context.Add(trailer);
await _context.SaveChanges();
trailer.Truck = truck;
return View(trailer);
}
public Task<ActionResult> UpdateTrailer(Trailer trailer)
{
ArgumentNullException.ThrowIfNull(trailer, nameof(trailer));
var truck = trailer.Truck;
trailer.Truck = null; // we will rely on trailer.TruckId
_context.Update(trailer); // Still not recommended...
await _context.SaveChanges();
trailer.Truck = truck;
return View(trailer);
}
When we remove the Truck navigation property reference before adding/updating then EF will just rely on the FK. In this example if we want to go back to a View of the trailer entity we copy back the Truck reference before returning. Alternatively it's often better to have the view reload the data.
That should hopefully get you on the right path for things to check when working with entities. My overall advice though with web applications is to use POCO view models for the data going back and forth between server and views rather than passing entities. It helps avoid confusion/errors, and boosts performance and security by passing less data back and forth.