Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.
Use the actual framework operator rather than a similarly named helper
LeftJoin is a genuine .NET10 LINQ operator recognized by EF Core10. It keeps every outer row and combines matching inner rows where they exist. A custom extension with that name would not establish the same translation contract, and a .NET9 browser practice should not pretend to execute a newer framework API. This lesson therefore supplies a complete external net10.0 project for the real query and a smaller .NET9 formatting exercise. The missing-inner policy is explicit: an unmatched blog is represented by a placeholder title, not by inventing a Post entity that never existed in storage.
Inspect LEFT JOIN and then verify matched and unmatched rows
The fixture seeds blog A with One and blog B without a post. Its provider-backed Blogs query calls LeftJoin with Posts using blog key and BlogId selectors, then projects Blog and Title and orders the result. ToQueryString must contain LEFT JOIN, and actual SQLite materialization must yield A:One and B:[none]. Both checks matter. A matching Enumerable result would not prove SQL translation, while a SQL string alone would not prove that unmatched data survived the query and was formatted correctly. The model’s relationship configuration and absence of lazy-loading proxies are concrete parts of this small fixture.
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using var db = new LabDb(options); await db.Database.EnsureCreatedAsync(); db.Blogs.AddRange(new Blog { Name = "A", Posts = { new Post { Title = "One" } } }, new Blog { Name = "B" }); await db.SaveChangesAsync(); db.ChangeTracker.Clear();
var query = db.Blogs.LeftJoin(db.Posts, b => b.Id, p => p.BlogId, (b, p) => new { Blog = b.Name, Title = p == null ? "[none]" : p.Title }).OrderBy(x => x.Blog).ThenBy(x => x.Title);
Check(query.ToQueryString().Contains("LEFT JOIN", StringComparison.OrdinalIgnoreCase), "actual EF10 left join translation");
var rows = await query.ToListAsync(); Check(string.Join(",", rows.Select(x => x.Blog + ":" + x.Title)) == "A:One,B:[none]", "matched and unmatched materialized");
foreach (var row in rows) Console.WriteLine(row.Blog + ":" + row.Title);
static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
The .NET9 lab formats already supplied optional matches
The BCL practice receives two Match records, one with a title and one with null. Null coalescing produces [none] for the second and leaves One unchanged for the first. The resulting two lines are intentionally the same presentation values as the external result, but their evidence differs. No join was discovered, no expression tree translated and no SQL command executed. This keeps the portable practice useful for a real application rule while admitting the runtime boundary. The pure formatter can be checked separately from the data adapter that must establish which optional match actually belongs to each row.
A left join preserves outer rows but can multiply them
An outer row with several matching inner rows produces several result pairs; preserving the outer side does not mean exactly one output row per blog. Decide whether the caller needs a flat detail report, an aggregate or a grouped shape. Filtering the projected inner side after a left join can also change which unmatched rows remain, depending on the predicate. Do not move conditions casually and assume join semantics are unchanged. Translation support is provider and version dependent, so the SQLite assertion does not certify another database’s query plan or imply that C# query syntax expresses this new operator directly.
Practice: add multiple inner matches and an unmatched case
Add another post under A and predict three flat output rows across the two blogs. Verify the actual SQL and ordered materialized values. Then compare an intended filter on inner posts before joining with a filter after projection, documenting what should happen to B. Use independent expected rows instead of counting only the total. Keep the unmatched placeholder separate from an actual post whose title happens to contain similar text. This exercise demonstrates relational cardinality and null handling through the real provider, which the supplied-formatting lab cannot establish.
- Hint: one outer row can have zero, one or several inner matches.
- Hint: predicate placement can change preservation of unmatched rows.
Practice: define a missing-value display contract
Extend the portable formatter with an empty title, a whitespace title and a genuinely absent title. Decide whether those are three distinct states or whether validation rejects some before presentation. Test the chosen labels without introducing a homemade LeftJoin implementation and claiming EF support. If a result needs to distinguish absence from a literal placeholder string, return structured optional data until the display boundary. The formatter’s deterministic output should remain valid on .NET9, while the external project continues to carry the separate framework and provider requirements for actual relational joining.
- Hint: null and a present empty string need not have the same meaning.
- Hint: keep display placeholders out of database identity and matching rules.
Keep API version, translation and result evidence together
The downloaded project establishes real .NET10 LeftJoin recognition by EF10, SQLite LEFT JOIN generation and the expected matched/unmatched materialization. The .NET9 practice establishes only optional-title formatting. Neither claims performance on production data, compatibility with another database or a universal one-row-per-outer result. A useful review names the operator version, join keys, multiplicity, predicate placement and missing-value policy. Those details make the newer API easier to use safely than copying a familiar join-shaped expression and assuming its SQL, cardinality and runtime availability are automatically the same everywhere.
.NET10 / EF Core10 / real SQLite — .NET10 LeftJoin translated by EF10
Set up the SDK project
- Use an external .NET10 console environment. This downloadable project targets net10.0 and pins Microsoft.EntityFrameworkCore.Sqlite 10.0.14; it is not the SharpRune .NET9 browser practice.
- Restore this project with: dotnet restore EfLab.csproj --source https://api.nuget.org/v3/index.json. Use an installed compatible .NET10 SDK; validation used the isolated official SDK10.0.403.
- Run: dotnet run --project EfLab.csproj --no-restore. The source opens a real SQLite :memory: connection and keeps that connection alive across its sequential context scopes. No EF InMemory provider or fake ORM is used.
- Read the embedded Check assertions and the expected output below. EnsureCreated is used only for disposable classroom schema unless this is the dedicated migration lesson. A passing SQLite run is not evidence for another database provider.
Download complete SDK project ↗
Project files
EfLab.csproj
Download file ↗<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
</ItemGroup>
</Project>
Program.cs
Download file ↗using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using var db = new LabDb(options); await db.Database.EnsureCreatedAsync(); db.Blogs.AddRange(new Blog { Name = "A", Posts = { new Post { Title = "One" } } }, new Blog { Name = "B" }); await db.SaveChangesAsync(); db.ChangeTracker.Clear();
var query = db.Blogs.LeftJoin(db.Posts, b => b.Id, p => p.BlogId, (b, p) => new { Blog = b.Name, Title = p == null ? "[none]" : p.Title }).OrderBy(x => x.Blog).ThenBy(x => x.Title);
Check(query.ToQueryString().Contains("LEFT JOIN", StringComparison.OrdinalIgnoreCase), "actual EF10 left join translation");
var rows = await query.ToListAsync(); Check(string.Join(",", rows.Select(x => x.Blog + ":" + x.Title)) == "A:One,B:[none]", "matched and unmatched materialized");
foreach (var row in rows) Console.WriteLine(row.Blog + ":" + row.Title);
static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Model.cs
Download file ↗using Microsoft.EntityFrameworkCore;
public sealed class LabDb : DbContext
{
public LabDb(DbContextOptions<LabDb> options) : base(options) { }
public DbSet<Blog> Blogs => Set<Blog>(); public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Post>().HasOne(p => p.Blog).WithMany(b => b.Posts).HasForeignKey(p => p.BlogId).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class Blog { public int Id { get; set; } public string Name { get; set; } = ""; public List<Post> Posts { get; set; } = new(); }
public sealed class Post { public int Id { get; set; } public string Title { get; set; } = ""; public int BlogId { get; set; } public Blog Blog { get; set; } = null!; }
Expected result
The external program must complete its Check assertions and print: A:One B:[none] It uses actual SQLite materialization and saving; no external service or production database is contacted.
Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.
C# companion practice
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
var supplied = new[] { new Match("A", "One"), new Match("B", null) };
foreach (var row in supplied) Console.WriteLine(row.Blog + ":" + (row.Title ?? "[none]"));
record Match(string Blog, string? Title);
C# practice output
A:One B:[none]
The new operator requires .NET10; its SQLite translation must be verified independently of a .NET9 formatting lab.