Lesson 0025 · Fondasi C# · ±30 menit (15 materi + kuis, 15 latihan tulis) · suplemen setelah 0003

LINQ untuk live coding

Peta array.* → LINQ, First vs Single, GroupBy dan top-N per grup, apa yang bisa diterjemahkan EF Core, multiple enumeration

Target lesson ini: saat diminta "Tulis LINQ untuk 3 Product termahal per Category, lalu jelaskan", Anda bisa menulisnya tanpa melihat catatan dan menjelaskannya dalam 90 detik. Anda juga bisa menjawab jebakan klasik "Apa beda First, FirstOrDefault, dan Single?"

Prasyarat: 0003 (deferred execution, IEnumerable vs IQueryable). Konsep itu tidak diulang di sini; lesson ini soal menulis.

1. Peta array.* → LINQ

Operator LINQ adalah extension method pada IEnumerable<T> (in-memory) dan IQueryable<T> (EF Core). Operator yang mengembalikan satu nilai, seperti Sum atau Average, "execute immediately". Operator yang mengembalikan sequence ditunda (deferred). [1] Sebagian besar sudah Anda kenal dari TS:

TypeScriptLINQCatatan
filterWhere
mapSelectSatu hasil per elemen
flatMapSelectMany"flattens them into one sequence" [3]
sort((a, b) => …)OrderBy / OrderByDescending, lalu ThenBy / ThenByDescendingKunci kedua = ThenBy, bukan OrderBy lagi. Tidak mengubah list asal.
slice(s, s + n)Skip(s).Take(n)Paging
findFirstOrDefaultLihat bagian 2
some / everyAny / AllAll pada sequence kosong = true [14]
includesContains
reduce untuk totalCount, Sum, Average, Max, MinMengembalikan angka
lodash maxBy / uniqByMaxBy, MinBy, DistinctByBaru di .NET 6 [9]; mengembalikan elemen, bukan nilainya
lodash groupBy / keyByGroupBy, ToDictionaryLihat bagian 3
var murahTersedia = products
    .Where(p => p.Stock > 0)
    .OrderBy(p => p.CategoryId)
    .ThenByDescending(p => p.Price)       // kunci kedua
    .Select(p => new { p.Name, p.Price }) // projection ke anonymous type
    .ToList();

Query syntax cukup Anda kenali. Compiler menerjemahkannya menjadi pemanggilan method yang sama; keduanya "semantically identical". [2] Operator seperti Count, Max, dan First hanya tersedia sebagai method, jadi untuk live coding pakai method syntax.

var q1 = from p in products where p.Stock == 0 orderby p.Name select p.Name;   // query syntax
var q2 = products.Where(p => p.Stock == 0).OrderBy(p => p.Name).Select(p => p.Name); // method syntax

2. Satu elemen: First, Single, dan versi …OrDefault

Bedanya hanya pada dua pertanyaan: apa yang terjadi kalau tidak ada yang cocok, dan apa yang terjadi kalau lebih dari satu yang cocok.

Tidak ada yang cocokLebih dari satu cocokKapan dipakai
First [4]InvalidOperationExceptionambil yang pertamaData pasti ada; ambil satu dari banyak
FirstOrDefault [5]default (null untuk reference type)ambil yang pertamaPadanan find; boleh kosong
Single [6]InvalidOperationExceptionInvalidOperationExceptionHarus tepat satu (invarian)
SingleOrDefault [7]defaultInvalidOperationExceptionCari berdasarkan primary key / kolom unik

"The default value for reference and nullable types is null". Untuk int, nilainya 0, dan itu sering membingungkan. [5] Sejak .NET 6 ada overload dengan nilai default sendiri: FirstOrDefault(p => …, fallback). [9] Keempat operator ini mengeksekusi query saat itu juga. Di EF Core, pakai versi async-nya (FirstOrDefaultAsync, SingleOrDefaultAsync).

// Controller GET /products/{id}
var p = await _db.Products.SingleOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();

3. GroupBy, top-N per grup, Join

GroupBy menghasilkan IEnumerable<IGrouping<TKey, T>>. Setiap grup punya .Key, dan grup itu sendiri adalah sequence elemennya, sehingga Anda bisa langsung memanggil Count(), Sum(), atau OrderBy() di atasnya. Grup muncul sesuai urutan elemen pertama yang membawa kuncinya. [8]

// Jumlah Product dan total stok per Category
var ringkas = products
    .GroupBy(p => p.CategoryId)
    .Select(g => new { CategoryId = g.Key, Jumlah = g.Count(), Stok = g.Sum(p => p.Stock) });

// Top-3 termahal per Category: urutkan DI DALAM grup, baru Take
var top3 = products
    .GroupBy(p => p.CategoryId)
    .Select(g => new { CategoryId = g.Key, Top = g.OrderByDescending(p => p.Price).Take(3).ToList() });

// Gabung dengan Category: Join (= INNER JOIN), atau dictionary untuk lookup cepat
var denganNama = products.Join(categories, p => p.CategoryId, c => c.Id, (p, c) => new { p.Name, Category = c.Name });
var namaCategory = categories.ToDictionary(c => c.Id, c => c.Name);   // lalu namaCategory[g.Key]

// Cari duplikat: grup yang anggotanya lebih dari satu
var namaDuplikat = products.GroupBy(p => p.Name).Where(g => g.Count() > 1).Select(g => g.Key);

Ini sama dengan top-N per grup dengan ROW_NUMBER() OVER (PARTITION BY …) di 0019, hanya ditulis di C#. Join hanya mengembalikan pasangan yang cocok, jadi Category tanpa Product hilang dari hasil. Kalau Category kosong juga harus muncul, mulai dari categories dan hitung Product-nya di dalam Select (lihat latihan D).

4. Apakah EF Core bisa menerjemahkannya ke SQL?

Pada IQueryable, LINQ Anda diterjemahkan menjadi SQL, dan tidak semua operator punya padanan SQL. Aturan EF Core: method C# biasa hanya boleh dipanggil di top-level projection ("essentially, the last call to Select()"). Kalau ekspresi yang tidak bisa diterjemahkan muncul "in any place other than the top-level projection", EF Core "throws a runtime exception". [11] Exception ini baru muncul saat aplikasi jalan, bukan saat compile.

// ✅ method C# di Select terakhir: SQL mengambil data, FormatNama jalan di .NET
await _db.Products.Select(p => new { Nama = FormatNama(p.Name) }).ToListAsync();
// ❌ method C# di Where: runtime exception (sebelum EF Core 3.0: diam-diam menarik semua baris)
await _db.Products.Where(p => FormatNama(p.Name).Contains("pro")).ToListAsync();

5. Multiple enumeration

Variabel query menyimpan definisinya, bukan hasilnya. "The enumeration result is not calculated once and cached". Jadi setiap Count(), foreach, atau ToList() menjalankan query dari awal lagi. Kalau sumbernya database, "multiple enumerations would hurt the performance". [13]

// ❌ dua query ke database
var q = _db.Products.Where(p => p.Stock == 0);
var n = await q.CountAsync();
foreach (var p in q) { … }

// ✅ satu query: materialisasi sekali, lalu pakai list-nya
var habis = await _db.Products.Where(p => p.Stock == 0).ToListAsync();
var n = habis.Count;
foreach (var p in habis) { … }

EF Core menyebut hal yang sama: "if you're enumerating multiple times, then storing results in a list helps more since there's only one query to the database". [11] Padanan Node: memanggil fungsi yang mengembalikan prisma.product.findMany(…) dua kali.

Latihan 1 — Pilih operator, tebak hasilnya

Jawab tanpa scroll ke atas. Soal yang sama akan muncul lagi, diacak, di review campuran 0007.

Kuis di lesson ini adalah feedback cepat, bukan bukti penguasaan; yang dicatat hanya hasil di review campuran 0007.

Latihan 2 — Tulis sendiri di lab (±15 menit)

Butuh .NET 8 SDK (ROADMAP Hari 0). Langkahnya:

  1. dotnet new console -o LinqLab && cd LinqLab
  2. Ganti isi Program.cs dengan skrip di bawah (tombol copy).
  3. dotnet run. Cocokkan output tiga demo dengan komentar // … di sebelahnya.
  4. Tulis jawaban latihan A–D di bawah setiap // TODO, jalankan lagi, lalu bandingkan dengan output yang diharapkan sebelum membuka jawaban.
// Lesson 0025 · LINQ untuk live coding. Lab in-memory, .NET 8.
// Cara pakai: dotnet new console -o LinqLab && cd LinqLab
//             ganti isi Program.cs dengan file ini, lalu: dotnet run
// Data sengaja memuat kasus uji: nama Product duplikat (Mouse, Kaos),
// Category tanpa Product (Mainan), dan Product dengan Stock 0.

var categories = new List<Category>
{
    new(1, "Elektronik"),
    new(2, "Buku"),
    new(3, "Pakaian"),
    new(4, "Mainan"),
    new(5, "Dapur"),
};

var products = new List<Product>
{
    new(1,  "Laptop",   15000000m, 5,  1),
    new(2,  "Mouse",      250000m, 0,  1),
    new(3,  "Keyboard",   750000m, 12, 1),
    new(4,  "Monitor",   3000000m, 3,  1),
    new(5,  "Headset",    500000m, 0,  1),
    new(6,  "Novel",      120000m, 20, 2),
    new(7,  "Kamus",      200000m, 0,  2),
    new(8,  "Komik",       50000m, 40, 2),
    new(9,  "Kaos",       100000m, 30, 3),
    new(10, "Jaket",      450000m, 0,  3),
    new(11, "Kaos",        95000m, 10, 3),
    new(12, "Wajan",      300000m, 7,  5),
    new(13, "Mouse",      180000m, 8,  1),
    new(14, "Panci",      350000m, 2,  5),
    new(15, "Spatula",     40000m, 25, 5),
};

// ---------- Demo 1: First / FirstOrDefault / Single / SingleOrDefault ----------
Console.WriteLine("== Demo 1: satu elemen ==");
Console.WriteLine(products.First(p => p.Name == "Mouse").Id);                      // 2 (yang pertama)
Console.WriteLine(products.FirstOrDefault(p => p.Name == "Drone") is null);       // True
Console.WriteLine(products.SingleOrDefault(p => p.Id == 99) is null);             // True
try
{
    products.Single(p => p.Name == "Mouse");                                       // ada dua Mouse
}
catch (InvalidOperationException)
{
    Console.WriteLine("Single: InvalidOperationException (lebih dari satu)");
}

// ---------- Demo 2: multiple enumeration ----------
Console.WriteLine("== Demo 2: multiple enumeration ==");
int calls = 0;
var mahal = products.Where(p => { calls++; return p.Price > 1000000m; });
var jumlah = mahal.Count();      // enumerasi ke-1
var daftar = mahal.ToList();     // enumerasi ke-2: predicate jalan lagi
Console.WriteLine($"{jumlah} produk, predicate dipanggil {calls} kali");          // 2 produk, 30 kali

// ---------- Demo 3: query syntax = method syntax ----------
Console.WriteLine("== Demo 3: query syntax ==");
var q1 = from p in products where p.Stock == 0 orderby p.Name select p.Name;
var q2 = products.Where(p => p.Stock == 0).OrderBy(p => p.Name).Select(p => p.Name);
Console.WriteLine(string.Join(", ", q1));                                          // Headset, Jaket, Kamus, Mouse
Console.WriteLine(q1.SequenceEqual(q2));                                           // True

// ---------- Latihan: tulis jawaban Anda di bawah setiap TODO, lalu dotnet run ----------
Console.WriteLine("== Latihan A: 3 Product termahal per Category ==");
// TODO A

Console.WriteLine("== Latihan B: Category yang tidak punya Product dengan Stock 0 ==");
// TODO B

Console.WriteLine("== Latihan C: nama Product yang duplikat ==");
// TODO C

Console.WriteLine("== Latihan D: total nilai stok (Price x Stock) per Category, terbesar dulu ==");
// TODO D

record Category(int Id, string Name);
record Product(int Id, string Name, decimal Price, int Stock, int CategoryId);

A. 3 Product termahal per Category, tampilkan nama Category. Output yang diharapkan: Elektronik: Laptop 15000000, Monitor 3000000, Keyboard 750000, lalu Buku, Pakaian, dan Dapur (Mainan tidak muncul karena tidak punya Product).

Lihat jawaban
var namaCategory = categories.ToDictionary(c => c.Id, c => c.Name);
var top3 = products
    .GroupBy(p => p.CategoryId)
    .Select(g => new
    {
        Category = namaCategory[g.Key],
        Top = g.OrderByDescending(p => p.Price).Take(3).Select(p => $"{p.Name} {p.Price}")
    });
foreach (var x in top3) Console.WriteLine($"{x.Category}: {string.Join(", ", x.Top)}");
// Elektronik: Laptop 15000000, Monitor 3000000, Keyboard 750000
// Buku: Kamus 200000, Novel 120000, Komik 50000
// Pakaian: Jaket 450000, Kaos 100000, Kaos 95000
// Dapur: Panci 350000, Wajan 300000, Spatula 40000

Grup muncul sesuai urutan Product pertama tiap Category di list. Nama Category diambil dari dictionary, satu kali, bukan dengan categories.First(…) di dalam Select.

B. Category yang tidak punya satu pun Product dengan Stock 0. Output yang diharapkan: Mainan, Dapur.

Lihat jawaban
var aman = categories
    .Where(c => !products.Any(p => p.CategoryId == c.Id && p.Stock == 0))
    .Select(c => c.Name);
Console.WriteLine(string.Join(", ", aman));   // Mainan, Dapur

Mainan ikut muncul karena tidak punya Product sama sekali. Versi products.Where(p => p.CategoryId == c.Id).All(p => p.Stock > 0) memberi hasil yang sama, karena All pada sequence kosong bernilai true. Kalau Category kosong tidak boleh ikut, tambahkan syarat products.Any(p => p.CategoryId == c.Id). Tanyakan ini ke interviewer: itu poin plus.

C. Nama Product yang duplikat. Output yang diharapkan: Mouse, Kaos.

Lihat jawaban
var duplikat = products
    .GroupBy(p => p.Name)
    .Where(g => g.Count() > 1)
    .Select(g => g.Key);
Console.WriteLine(string.Join(", ", duplikat));   // Mouse, Kaos

Pola "GroupBy → filter jumlah anggota" sama dengan GROUP BY Name HAVING COUNT(*) > 1, dan di EF Core memang diterjemahkan menjadi itu.

D. Total nilai stok (Price × Stock) per Category, terbesar dulu, termasuk Category tanpa Product. Output yang diharapkan: Elektronik: 94440000, Buku: 4400000, Pakaian: 3950000, Dapur: 3800000, Mainan: 0.

Lihat jawaban
var nilai = categories
    .Select(c => new
    {
        c.Name,
        Total = products.Where(p => p.CategoryId == c.Id).Sum(p => p.Price * p.Stock)
    })
    .OrderByDescending(x => x.Total);
foreach (var x in nilai) Console.WriteLine($"{x.Name}: {x.Total}");

Mulai dari categories supaya Mainan tetap muncul dengan 0 (Sum dari sequence kosong = 0). Versi products.GroupBy(p => p.CategoryId) atau Join lebih ringkas, tapi Mainan hilang dari hasilnya.

Latihan 3 — Ucapkan dengan suara keras

Tulis kodenya di kertas, lalu pasang timer 90 detik dan jelaskan dengan suara keras. Jangan buka jawaban contoh sebelum selesai.

"Tulis LINQ untuk 3 Product termahal per Category (tampilkan nama Category), lalu jelaskan setiap bagiannya."
Bandingkan dengan jawaban contoh dan checklist-nya
var nama = categories.ToDictionary(c => c.Id, c => c.Name);
var top3 = products
    .GroupBy(p => p.CategoryId)
    .Select(g => new {
        Category = nama[g.Key],
        Top = g.OrderByDescending(p => p.Price).Take(3).ToList()
    });

"GroupBy membuat satu IGrouping per CategoryId; Key-nya CategoryId. Di dalam setiap grup saya urutkan dari Price termahal lalu Take(3), jadi urutannya harus OrderByDescending dulu baru Take. Nama Category saya ambil lewat dictionary sekali jalan, bukan query per Category di dalam loop. Di SQL ini setara ROW_NUMBER() OVER (PARTITION BY CategoryId ORDER BY Price DESC). Kalau sumbernya EF Core, query tetap IQueryable sampai ToListAsync, dan sejak EF Core 6 pola top-N per grup ini diterjemahkan ke SQL dengan ROW_NUMBER."

Checklist: apakah Anda…

  • memakai GroupBy per CategoryId dan menyebut IGrouping dengan .Key?
  • menulis OrderByDescending(Price) dulu, baru Take(3), di dalam grup?
  • mengambil nama Category lewat dictionary/Join/navigation, bukan query per Category di loop?
  • menulis kode yang sintaksnya benar (lambda, new { … }, kurung)?
  • menyebut padanan SQL ROW_NUMBER() OVER (PARTITION BY … ORDER BY … DESC)?
  • menyebut bahwa di EF Core query tetap IQueryable sampai ToListAsync, dan EF Core 6+ menerjemahkan top-N per grup?

Lulus kalau minimal 4 dari 6. Kalau kurang, ulangi besok pagi sebelum lesson berikutnya (spacing). Review 0007 juga bisa menanyakan "First vs FirstOrDefault vs Single vs SingleOrDefault" secara lisan, dengan checklist yang sama gayanya (lihat reference).

Bacaan utama (±10 menit) Microsoft Learn: Standard query operators overview, lalu EF Core: Complex Query Operators bagian "Join" dan "GroupBy".

Ringkasan untuk dicetak: Reference: LINQ untuk live coding · Istilah: GLOSSARY.md

Ada yang belum jelas? Tanyakan ke saya (agent) di terminal, misalnya: "kenapa g.Take(3).OrderByDescending(...) salah?" atau "bagaimana menulis latihan A langsung di EF Core dari _db.Categories?"

Sumber: [1] Standard query operators overview · [2] Write LINQ queries · [3] Projection operations · [4] Enumerable.First · [5] Enumerable.FirstOrDefault · [6] Enumerable.Single · [7] Enumerable.SingleOrDefault · [8] Enumerable.GroupBy · [9] What's new in .NET 6: New LINQ APIs · [10] EF Core: Complex Query Operators · [11] EF Core: Client vs. Server Evaluation · [12] What's new in EF Core 6: Improved GroupBy support · [13] CA1851: Possible multiple enumerations · [14] Enumerable.All