Tuesday, February 23, 2021

EF Core: Using HiLo Algorithm to Generate IDs

In this post, let's see how we can use Hi/Lo algorithm as the key generation strategy in EF Core. This can be achieved using UseHiLo method.

As always let's go by an example.

Consider the below code.
public class Category
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<Product> Products { getset; }
}

public class Product
{
    public int Id { getset; }

    public string Name { getset; }

    public int CategoryId { getset; }
}
And MyDbContext is setup like this.
public class MyDbContext : DbContext
{
    public DbSet<Category> Categories { getset; }

    public DbSet<Product> Products { getset; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer("ConnectionString")
            .LogTo(Console.WriteLineLogLevel.Information);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Category>(builder =>
        {
            builder
                .HasMany(x => x.Products)
                .WithOne()
                .HasForeignKey(x => x.CategoryId);
        });
    }
}
And now I am adding a Category and a Product.
using var context = new MyDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

var category = new Category() { Name = "Some Category" };
context.Categories.Add(category);

var product = new Product { Name =
"Some Product", CategoryId = category.Id };
context.Products.Add(product);

await context.SaveChangesAsync()
The SaveChanges here will fail here with the following error: "The INSERT statement conflicted with the FOREIGN KEY constraint "FK_Products_Categories_CategoryId"". The reason is, we are setting the CategoryId of the Product, but it's still 0 because the Category record is still not saved to the database.

In this kind of a scenario, using HiLo algorithm to determine the Id before the record is actually saved in the database is quite handy.

Enabling HiLo is pretty easy. You can either enable it for all the entities or for a specific entity.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // HiLo Id generation For all the entities
    //modelBuilder.UseHiLo();

    modelBuilder.Entity<Category>(builder =>
    {
        builder
            .HasMany(x => x.Products)
            .WithOne()
            .HasForeignKey(x => x.CategoryId);

        // HiLo Id generation only for Category
        builder
            .Property(x => x.Id)
            .UseHiLo("categoryseq");
    });
}
After this change, if you run the example, you are not going to see the previous error, instead, you should see Category now has a valid Id.
Valid CategoryId
If you have a second category added, it will have an Id of value 2.

Here something important to note is if you do UseHilo() for all the entities, the Ids are going to be kind of messed up, if you take the above example, the Id of the Product here will be 2.

Now let's try 2 simultaneous adding. While the control is waiting on SaveChanges, I am starting another instance of the application. 
Another Instance
Here the new category has the Id of value 11.

Now how is this working?

If you add in database migration, you will see EF is creating a Sequence, something like below.
migrationBuilder.CreateSequence(
    name"categoryseq",
    incrementBy: 10);
And once the database is updated, you can see the created sequence here.
Sequences
categoryseq
Now let's imagine so far we have only created the database and no data is inserted.

Now we are creating a new database context and adding a category to context (context.Categories.Add(category)), EF will run a query like this.
SELECT NEXT VALUE FOR [categoryseq]
This will return 1, and the category will be assigned the value of 1 and it has now blocked Ids from 1 to 10. If we add in another category in the same database context, EF won't run the above query again, it will increment the Id by 1 and when we have added 10 categories, EF will run the above query again to get the next value, which will be 11. But imagine while we are adding these 10 categories, some other user creates a new database context and starts adding categories, then that process will also call the above query, so this time he will get 11 (because so far we have only blocked from 1 to 10). And when we call for next value, we will get 21, because that process has now blocked 11 to 20.

Isn't it nice? But there is a kind of downside here. This can cause missing Ids (if you are worried about the order of the Ids), but generally, it shouldn't be an issue.

Hope this helps!

Happy Coding.

Regards,
Jaliya

Wednesday, February 10, 2021

EF Core: Owned Entity Types

In this post let's see what is Owned Entity Types in Entity Framework Core. This is a very nice feature when it comes to DDD.

Owned Entities are entities that can be only appeared on navigation properties of other entity types. Basically, they can't exist without the Owner.  

Let's go by an example. Consider the following two entities.
public class User
{
    public int Id { getset; }

    public string Name { getset; }

    public Address Address { getset; }
}
public class Address
{
    public string Street { getset; }

    public string City { getset; }
}
And my DbContext looks like this.
public class MyDbContext : DbContext
{
    public DbSet<User> Users { getset; }

    //...
}
So here, we can use OwnsOne to configure the Address Owned Entity to the Owner User as below (I am using Fluent API instead of annotations, you can annotations if you prefer, but I prefer Fluent API all the time).
public class UserEntityConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<Userbuilder)
    {
        builder.ToTable($"{nameof(User)}");

        builder.OwnsOne(x => x.Address);
    }
}
And this will create something like this.
dbo.User
If you don't like the default naming convention, you can always override the behavior like below.
public class UserEntityConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<Userbuilder)
    {
        builder.ToTable($"{nameof(User)}");

        builder.OwnsOne(x => x.Address, y =>
        {
            y.Property(y => y.City)
                    .HasColumnName("City");

            y.Property(y => y.Street)
                    .HasColumnName("Street");
        });
    }
}
And this would give something like below.
dbo.User

Now let's see consider the following code to insert some data and retrieval.
using var context = new MyDbContext();

User user = new User
{
    Name = "John Doe",
    Address = new Address { Street = "Some Street1", City = "Some City1" }
};

await context.Users.AddAsync(user);
await context.SaveChangesAsync();
using var anotherContext = new OrderDbContext();

user = await anotherContext.Users.FirstOrDefaultAsync();
Here for selection, EF Core will generate a query as below.
SELECT TOP(1) [u].[Id], [u].[Name], [u].[City], [u].[Street]
FROM [User] AS [u]
So here you should be able to see a very important thing. When we are getting User, we didn't have to explicitly Include Address to load Address details, it's loading its Owned Entities by default. This might not be a good example, since it's in the same table, we will see a clearer example when we are going through OwnsMany later in this post. 

Note: I am using anotherContext here for retrieval, because EF Core will automatically fix-up navigation properties to any other entities that were previously loaded into the context instance. So even if you don't explicitly include the data for a navigation property, the property may still be populated if some or all of the related entities were previously loaded.

Above was kind of a one-to-one relationship. Now let's have a look at one-to-many type of relationship.
public class User
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<Address> Addresses { getset; }
}
public class Address
{
    public string Street { getset; }

    public string City { getset; }

    public string Type { getset; }
}
Now I can use OwnsMany as below.
public class UserEntityConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<Userbuilder)
    {
        builder.ToTable($"{nameof(User)}");

        builder.OwnsMany(x => x.Addresses, y =>
        {
            y.ToTable("UserAddress");

            y.Property(y => y.City)
                .HasColumnName("City");

            y.Property(y => y.Street)
                .HasColumnName("Type");
            y.Property(y => y.Street)
                .HasColumnName("Type");
        });
    }
}
And this would give something like this.
dbo.User and dbo.UserAddress
Now let's have a look at below code to insert some data and retrieval.
using var context = new MyDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

var user = new User
{
    Name = "John Doe",
    Addresses = new List<Address>
    {
        new Address { Street = "Some Street1", City = "Some City1", Type= "Shipping" },
        new Address { Street = "Some Street2", City = "Some City2", Type= "Mailing" }
    }
};

await context.Users.AddAsync(user);
await context.SaveChangesAsync();

using var anotherContext = new MyDbContext();

user = await anotherContext.Users.FirstOrDefaultAsync();
Here for selection, the generated query would be the following. (you can basically ignore the subquery, that's because I am getting FirstOrDefault),
SELECT [t].[Id], [t].[Name], [u0].[UserId], [u0].[Id], [u0].[City], [u0].[Street], [u0].[Type]
FROM (
    SELECT TOP(1) [u].[Id], [u].[Name]
    FROM [User] AS [u]
) AS [t]
LEFT JOIN [UserAddress] AS [u0] ON [t].[Id] = [u0].[UserId]
ORDER BY [t].[Id], [u0].[UserId], [u0].[Id]
And here also, without doing .Include(x => x.Addresses), the addresses are being returned.

So that's about it. You can read more on Owned Entity Types by going to the below link.
There are some restrictions when it comes to Owned Entities which makes perfect sense.
  • You cannot create a DbSet<T> for an owned type.
  • You cannot call Entity<T>() with an owned type on ModelBuilder.
  • Instances of owned entity types cannot be shared by multiple owners (this is a well-known scenario for value objects that cannot be implemented using owned entity types).
Hope this helps.

Happy Coding.

Regards,
Jaliya

Wednesday, January 27, 2021

Azure Pipelines: Create/Update Azure API Management APIs

In this post, let's see how we can create/update Azure API Management APIs from both classic release pipelines and YAML based pipelines.

Here I am assuming, We have already created Azure API Management Service in Azure and we have set up Products.

The process is actually quite easy, thanks to this great free extension API Management Suite by Stephane Eyskens

First, let's have a look at how we can use this in classic release pipelines.

Once this extension is installed, you should be able to see a bunch of tasks starting with API Management - *. From there what we need is API Management - Create or Update API task. We just need to add this task after the deployment of the API.

API Management - Create or Update API

Then it's just a matter of configuring the task.
API Management - Create or Update API Settings
Most of the things here are pretty obvious, so I am not going to explain each one of them. The important ones are,
  • Products
    • Since I have already created a product in the APIM, I can just enter the name of the Product.
    • If you are creating the Product as part of the pipeline (using API Management - Create or update product task), then you need to select Product created by previous task.
  • OpenAPI Specification
    • The APIM needs to have access to OpenAPI specification in order to create the API. You have several options here. You need to select the version, it's format (json or yaml), any authorization needed to access the specification and the location (URL, Code or Build Artifact)
  • Configuration of the API
    • Here, the nice thing is you can specify the Policy. I prefer maintaining the policy in the code, so here I have just selected my policy by providing it's path.
So that's basically it. Once the API is deployed, this task will run, it will pull down the OpenAPI specification, create/update APIM API, apply the policy and it's all good to go.

If you prefer having deployment steps as part of YAML pipeline, we can easily set up this task there as well. Basically something like this.
task: stephane-eyskens.apim.apim.apim@5
    displayName: 'API Management - Create/Update API '
    inputs:
      ConnectedServiceNameARM: ''
      ResourceGroupName: ''
      ApiPortalName: ''
      UseProductCreatedByPreviousTask: false
      product1: ''
      OpenAPISpec: 'v3'
      swaggerlocation: ''
      targetapi: ''
      DisplayName: ''
      pathapi: ''
      subscriptionRequired: false
      TemplateSelector: Artifact
      policyArtifact: '$(Pipeline.Workspace)\scripts\apim\apim-policy.xml'
      MicrosoftApiManagementAPIVersion: '2018-01-01'
Don't worry, you have the UI to select the options just like classic releases.
API Management - Create or Update API Settings
So hope this helps. 

Huge thanks to Stephane Eyskens for coming up with this nice set of tasks.

Happy Coding.

Regards,
Jaliya

Thursday, January 14, 2021

Azure Pipelines: Passing Variables Between Jobs and Stages

In this post, let's see how we can pass variables between Stages and Jobs in Azure Pipelines..

Consider the following simple pipeline.

stages:
stage: Build
  jobs:
  - job: FirstJob
    pool:
      vmImage: 'windows-latest'
    steps:
     - bash: echo "##vso[task.setvariable variable=myVariable;isOutput=true]Hello World"
       name: stepSetVariable
       
  - job: SecondJob
    dependsOn: FirstJob
    pool:
      vmImage: 'ubuntu-latest'
    variables:
      # Map variable
      # syntax: $[ dependencies.{JobName}.outputs['{stepName}.{variableName}'] ]
      myJobVariable: $[ dependencies.FirstJob.outputs['stepSetVariable.myVariable'] ]  
    steps:
    # Echos Hello World
    - script: echo $(myJobVariable)
      name: stepEchoVariable

stage: Deployment
  dependsOn: Build
  pool:
    vmImage: 'macOS-latest'
  variables:
    # Map variable
    # syntax: $[ stageDependencies.{BuildName}.{JobName}.outputs['{stepName}.{variableName}'] ]
    myStageVariable: $[ stageDependencies.Build.FirstJob.outputs['stepSetVariable.myVariable'] ]
  jobs:
  - job: DeploymentJob
    steps:
    # Echos Hello World
    - script: echo $(myStageVariable)
      name: stepEchoVariable

Here,

  • I have 2 Stages, Build and Deployment
  • Build stage has 2 jobs, 
    • In the FirstJob, I am creating a variable called myVariable. I am making it an output variable and setting its value to Hello World.
    • In the SecondJob
      • The SecondJob is depending on FirstJob because the variable needs to be created first. 
      • Then I am declaring a variable called myJobVariable and mapping it with myVariable created in FirstJob. Since it's within the same Stage, I can map the variable using this syntax. 
$[ dependencies.{JobName}.outputs['{stepName}.{variableName}'] ]
      • Then I am just printing the value of myJobVariable
  • In Deployment stage,
    • This stage is depending on Build stage, again because we are trying to access an output variable from Build stage.
    • Then I am declaring a variable called myStageVariable and mapping it with myVariable created in Build stages' FirstJob. Since it's in a different Stage, I can reference the variable using stageDependencies
$[ stageDependencies.{BuildName}.{JobName}.outputs['{stepName}.{variableName}'] ]

    • Then I have just a single job to print the value of myStageVariable

Note: I have used different images for two jobs in Build stage and for the Deployment stage, so the concept is more clear.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, January 7, 2021

EF Core 5.0: Excluding Tables from Migrations

In this post, let's see how we can exclude Tables from Migrations in EF Core 5.0. It's actually pretty easy.

Consider the below use case.

public class IdentityDbContext : DbContext
{
    public DbSet<ApplicationUser> ApplicationUsers { getset; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // Configuring
    }
}

public class OrderDbContext : DbContext
{
    public DbSet<ApplicationUser> ApplicationUsers { getset; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // Configuring
    }
}

public class ApplicationUser
{
    public int Id { getset; }

    public string Name { getset; }
}

Here the ApplicationUser is shared between two DbContexts (IdentityDbContext and OrderDbContext). And now let's say between these two DbContexts, we are only going to maintain full ApplicationUser information in only IdentityDbContext. In that case, we don't want further changes on ApplicationUser to be applied on OrderDbContext.

To achieve that, we can exclude the ApplicationUser in migrations for OrderDbContext as below.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<ApplicationUser>().ToTable("ApplicationUsers", t => t.ExcludeFromMigrations());
}

Unfortunately, we need to call ToTable and from there customize the TableBuilder to ExcludeFromMigrations.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, December 29, 2020

EF Core 5.0: SaveChanges Events and Interceptors

In this post let's have a look at one of the new features that got introduced with EF Core 5.0. That is SaveChanges Events and Interceptors.

Events

Following events has been introduced,

  • DbContext.SavingChanges
    • This event is raised at the start of SaveChanges or SaveChangesAsync
  • DbContext.SavedChanges
    • This event is raised at the end of a successful SaveChanges or SaveChangesAsync
  • DbContext.SaveChangesFailed
    • This event is raised at the end of a failed SaveChanges or SaveChangesAsync
static async Task Main(string[] args)
{
    using var context = new MyDbContext();
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.SavingChanges += Context_SavingChanges;
    context.SavedChanges += Context_SavedChanges;
    context.SaveChangesFailed += Context_SaveChangesFailed;

    var john = new Student { Name = "John" };
    var jane = new Student { Name = "Jane" };

    context.AddRange(john, jane);
    await context.SaveChangesAsync();
}

private static void Context_SavingChanges(object sender, SavingChangesEventArgs args)
{
    Console.WriteLine($"Starting saving changes.");
}

private static void Context_SavedChanges(object sender, SavedChangesEventArgs args)
{
    Console.WriteLine($"Saved {args.EntitiesSavedCount} No of changes.");
}

private static void Context_SaveChangesFailed(object sender, SaveChangesFailedEventArgs args)
{
    Console.WriteLine($"Saving failed due to {args.Exception}.");
}

Here note that the event sender is always the DbContext instance.

Interceptors

Interceptors can be used for interception (as the name suggests of course), modification, and most importantly Interceptors allow SaveChanges to be suppressed unlike Events. It also provides async counterparts of methods so you can do async actions.

Interceptors needs to implement ISaveChangesInterceptor interface. It contains following method definitions.

  • SavingChanges
    • Called at the start of DbContext.SaveChanges
  • SavingChangesAsync
    • Called at the start of DbContext.SaveChangesAsync.
  • SavedChanges
    • Called at the end of DbContext.SaveChanges.
  • SavedChangesAsync
    • Called at the end of DbContext.SaveChangesAsync.
  • SaveChangesFailed
    • Called when there is an exception thrown from DbContext.SaveChanges.
  • SaveChangesFailedAsync
    • Called when there is an exception thrown from DbContext.SaveChangesAsync.

There is also a SaveChangesInterceptor abstract class included in the framework, so for your Interceptors you can derive from that and override only the required methods, rather than implementing all the methods in the ISaveChangesInterceptor.

So let's see some code. I can create a custom Interceptor like this.

public class MyCustomInterceptor : SaveChangesInterceptor
{

}

Then I need to tell EF to use this Interceptor.

public class MyDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer($@"Data Source=RAVANA-TPP50\MSSQLSERVER2017; Initial Catalog=EfCore5;User ID=sa;Password=sa")
            .AddInterceptors(new MyCustomInterceptor());
    }

    // Some Code
}

Now I can start customizing my Interceptor. For example, I am going to override SavingChangesAsync and SavedChangesAsync.

public class MyCustomInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Started saving changes.");

        return new ValueTask<InterceptionResult<int>>(result);
    }

    public override ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Saved {result} No of changes.");

        return new ValueTask<int>(result);
    }
}

Now if I add a record and do SaveChangesAsync(), I can see following output.

Started saving changes.
Saved 1 No of changes.

I have mentioned above, with Interceptors we can suppress the operations.

public class MyCustomInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Started saving changes.");

        result = InterceptionResult<int>.SuppressWithResult(0);

        return new ValueTask<InterceptionResult<int>>(result);
    }

    public override ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"Saved {result} No of changes.");

        return new ValueTask<int>(result);
    }
}

Here on SavingChangesAsync, I have Suppressed the operation. And now If I try SaveChangesAsync, I can see nowthing is getting Saved.

Started saving changes.
Saved 0 No of changes.

There is more to Interceptors like you can Intercept different low-level database operations such as Db Connection Interception, Db Command Interception, Transaction Interception etc. I suggest you read Interceptors for more information.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, December 15, 2020

Azure Functions: Change PlanType from Consumption (Serverless) to Premium

This is a quick post on how we can change the Plan Type of an Azure Function App from Consumption (Serverless) to a Premium Plan. If you have created an Azure Function App initially selecting Consumption (Serverless) as the plan type and then later if you want to change it to a Premium Plan, Azure Portal has no direct way to do it.

Here you have 2 options,

  1. Create a new Azure Function App with a Premium plan and redeploy code
  2. Create a new Azure Function Premium Plan and change the existing Function App to use the new plan through Azure CLI
The first option can be a pain, you basically need to setup everything and deploy the code. The second option is pretty easy.

First, we need to create a new Azure Function Premium Plan.
az functionapp plan create `
--resource-group <resource group> `
--name <new plan name> `
--location <location> `
--sku <EP1 or EP2 or EP3> `
--is-linux true
--is-linux is only if you want it to be Linux, well it's self-explanatory.

Next, we need to associate the existing Function App with the new plan.
az functionapp update `
--resource-group <resource group of the existing function app> `
--name <existing function app name> `
--plan <new plan name> `
That's easy. Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, December 3, 2020

EF Core 5.0: Many to Many Relationships

One of the nicest features available in EF Core 5.0 is now you can define a many-to-many relationship without explicitly mapping the join table. 

With earlier EF Core versions, you didn't have the luxury of doing something like this,

Example 1:

public class Student
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<Course> Courses { getset; }
}

public class Course
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<Student> Students { getset; }
}

You needed to explicitly create the join table, something like below.

Example 2:

public class Student
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<CourseStudent> CourseStudents { getset; }
}

public class Course
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<CourseStudent> CourseStudents { getset; }
}

public class CourseStudent
{
    public int Id { getset; }

    public Course Course { getset; }     public Student Student { getset; }
}

But with EF Core 5.0, you don't have to do that anymore, the code in Example 1 would work just fine. EF will understand we need a join table and it will create one automatically.

Database
You can use it like below.
using var context = new MyDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

var john = new Student { Name = "John" };
var jane = new Student { Name = "Jane" };
var tim = new Student { Name = "Tim" };

var csharp = new Course { Name = "C#", Students = new List<Student> { john, jane } };
var sql = new Course { Name = "SQL", Students = new List<Student> { tim } };

context.AddRange(john, jane, tim, csharp, sql);
await context.SaveChangesAsync();

List<Student> csharpStudents = await context.Students .Where(x => x.Courses.Any(sc => sc.Name == csharp.Name)) .ToListAsync();

foreach (Student student in csharpStudents)
{
    Console.WriteLine(student.Name);
}

Output,

John
Jane

Often times, you need additional data (known as payload properties) in the join table. In our case here, maybe the date that the student got enrolled with the Course. So I can design the data model like below. 

public class Student
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<Course> Courses { getset; }

    public ICollection<CourseStudent> CourseStudents { getset; }
}

public class Course
{
    public int Id { getset; }

    public string Name { getset; }

    public ICollection<Student> Students { getset; }

    public ICollection<CourseStudent> CourseStudents { getset; }
}

public class CourseStudent
{
    public Course Course { getset; }

    public Student Student { getset; }

    public DateTime EnrolledDate { getset; }
}

If you have noticed in the above code, you can even specify a navigation property to the Join table. Let's see how it can make our life easy in certain cases.

And EF Core allows full customization of the join table, something like below.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<Student>()
        .HasMany(x => x.Courses)
        .WithMany(x => x.Students)
        .UsingEntity<CourseStudent>(
            x => x
                .HasOne(x => x.Course)
                .WithMany(x => x.CourseStudents),
            x => x
                .HasOne(x => x.Student)
                .WithMany(x => x.CourseStudents),
            x =>
            {
                x.Property(x => x.EnrolledDate).HasDefaultValueSql("CURRENT_TIMESTAMP");
            });
}

Then I can do things like this.

using var context = new MyDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

var john = new Student { Name = "John" };
var jane = new Student { Name = "Jane" };
var tim = new Student { Name = "Tim" };

var csharp = new Course
{
Name = "C#",
    CourseStudents = new List<CourseStudent>
    {
        new CourseStudent
        {
            Student = john,
            EnrolledDate = new DateTime(2020, 06, 01)
        },
        new CourseStudent
        {
            Student = jane,
            EnrolledDate = new DateTime(2020, 10, 01)
        }
    }
};
var sql = new Course { Name = "SQL", Students = new List<Student> { tim } };

context.AddRange(john, jane, tim, csharp, sql);
await context.SaveChangesAsync();

List<Course> courses = await context.Courses
    .ToListAsync();

foreach (Course course in courses)
{
    Console.WriteLine($"{course.Name}");

    // If you don't want payload property information, you can just use Students 
    foreach (Student student in course.Students)
    {
     Console.WriteLine($"-- {student.Name}");
    }

    // If you want payload property information, you can use CourseStudents, the navigation to join table  
    foreach (CourseStudent courseStudent in course.CourseStudents)
    {
        Console.WriteLine($"-- {courseStudent.Student.Name} : {courseStudent.EnrolledDate}");
    }
}

Output,

C#
// Without using Join Table
-- John
-- Jane

// With using Join Table
-- John : 6/1/2020 12:00:00 AM
-- Jane : 10/1/2020 12:00:00 AM

SQL
// Without using Join Table
-- Tim

// With using Join Table
-- Tim : 12/3/2020 9:17:00 PM

Hope this helps.

To know more on EF Core 5, you can read: What's New in EF Core 5.0

Happy Coding.

Regards,
Jaliya