Chapter Array Query Operators di Section CRUD telah menunjukkan tugas utama $elemMatch: mengunci beberapa kondisi pada satu elemen array of documents — kasus ulasan yang harus memuat pembaca dan bintang dari ulasan yang sama. Bab ini tidak mengulang materi itu. Fokusnya adalah sudut yang belum dibahas: $elemMatch pada array nilai biasa (angka), tempat jebakan paling klasik MongoDB berada — dua operator rentang pada satu field array yang diam-diam dipenuhi elemen berbeda. Kita uji jebakannya, menutupnya dengan $elemMatch, lalu menimbang performa kedua bentuk tersebut.

Peta materi Chapter ini:

  • Jebakan query rentang ganda pada array skalar.
  • $elemMatch sebagai solusi, termasuk perilakunya terhadap nilai skalar.
  • Kombinasi $elemMatch dengan kondisi field lain dan pertimbangan performa.

Data Uji: Riwayat Diskon #

Setiap buku menyimpan diskon — array persentase diskon yang pernah diberikan. Rumah Kaca hanya pernah sekali turun harga, dan nilainya disimpan sebagai angka biasa, bukan array:

js
use latihan_query
db.buku.drop()

Output yang Dihasilkan:

text
switched to db latihan_query
true
js
db.buku.insertMany([
  { judul: "Laut Bercerita", penulis: "Leila S. Chudori", harga: 98000, kategori: "fiksi", diskon: [10, 50] },
  { judul: "Bumi", penulis: "Tere Liye", harga: 89000, kategori: "fiksi", diskon: [5, 25] },
  { judul: "Filosofi Teras", penulis: "Henry Manampiring", harga: 89000, kategori: "nonfiksi", diskon: [0, 15, 30] },
  { judul: "Atomic Habits", penulis: "James Clear", harga: 115000, kategori: "nonfiksi", diskon: [40, 60] },
  { judul: "Rumah Kaca", penulis: "Tere Liye", harga: 72000, kategori: "fiksi", diskon: 22 }
])

Output yang Dihasilkan:

text
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad3914b7d3ae03c17f55844'),
    '1': ObjectId('6ad3914b7d3ae03c17f55845'),
    '2': ObjectId('6ad3914b7d3ae03c17f55846'),
    '3': ObjectId('6ad3914b7d3ae03c17f55847'),
    '4': ObjectId('6ad3914b7d3ae03c17f55848')
  }
}

Pertanyaannya: buku mana yang pernah didiskon di antara 20% sampai 35%? Artinya, harus ada satu angka dalam riwayat yang berada di rentang itu.

Jebakan: Rentang Ganda Tanpa $elemMatch #

Pendekatan pertama yang terasa wajar — gabungkan $gte dan $lte langsung pada field array:

js
db.buku.find({ diskon: { $gte: 20, $lte: 35 } }, { _id: 0, judul: 1, diskon: 1 })

Output yang Dihasilkan:

text
[
  { judul: 'Laut Bercerita', diskon: [ 10, 50 ] },
  { judul: 'Bumi', diskon: [ 5, 25 ] },
  { judul: 'Filosofi Teras', diskon: [ 0, 15, 30 ] },
  { judul: 'Rumah Kaca', diskon: 22 }
]

Empat buku kembali, dan satu di antaranya salah. Mari kita periksa tiap kandidat terhadap maksud pertanyaan:

  1. Laut Bercerita [10, 50] — tidak ada satu pun angka di antara 20 dan 35, tetapi query meloloskannya. Syarat $gte: 20 dipenuhi 50, syarat $lte: 35 dipenuhi 10: dua elemen berbeda.
  2. Bumi [5, 25] — benar: 25 berada di rentang.
  3. Filosofi Teras [0, 15, 30] — benar: 30 berada di rentang.
  4. Rumah Kaca 22 — benar: nilainya memang di rentang. Karena field-nya skalar (bukan array), kedua operator diuji langsung pada angka 22 seperti query field biasa.

Inilah jebakan klasiknya: sesuai mekanisme yang dibahas di Chapter Querying Arrays, tiap operator pada field array diuji sendiri-sendiri terhadap seluruh elemen. $gte: 20 cukup dipenuhi elemen mana pun, $lte: 35 cukup dipenuhi elemen mana pun — tidak ada jaminan keduanya jatuh pada angka yang sama. Laut Bercerita adalah positif palsu, dan kesalahan seperti ini tidak terlihat dari hasil query: butuh pemeriksaan data untuk menemukannya.

Solusi: $elemMatch Mengunci Satu Elemen #

$elemMatch membungkus seluruh syarat menjadi satu paket yang diuji terhadap tiap elemen secara individual:

js
db.buku.find({ diskon: { $elemMatch: { $gte: 20, $lte: 35 } } }, { _id: 0, judul: 1, diskon: 1 })

Output yang Dihasilkan:

text
[
  { judul: 'Bumi', diskon: [ 5, 25 ] },
  { judul: 'Filosofi Teras', diskon: [ 0, 15, 30 ] }
]

Laut Bercerita hilang: tidak ada satu elemen yang sekaligus ≥ 20 dan ≤ 35. Mari kita bedah bentuknya:

  1. diskon: { $elemMatch: { ... } }: operator ditempelkan langsung pada field array, sama seperti di Chapter Array Query Operators.
  2. { $gte: 20, $lte: 35 }: isi $elemMatch adalah filter yang diuji utuh terhadap setiap elemen — bukan diuji per operator.
  3. Syarat lolos: minimal satu elemen memenuhi semua kondisi paket itu.

Tetapi perhatikan Rumah Kaca: buku dengan diskon 22 — tepat di dalam rentang — justru tidak muncul. Penyebabnya satu perbedaan penting: $elemMatch hanya mencocokkan field array. Nilai skalar 22 tidak pernah diuji oleh $elemMatch, sehingga document itu terlewat meskipun secara bisnis jawabannya benar.

Kondisi Tunggal: Operator Telanjang Cukup #

Untuk satu kondisi saja, $elemMatch tidak diperlukan. Bandingkan dua query “pernah didiskon lebih dari 20%”:

js
db.buku.find({ diskon: { $elemMatch: { $gt: 20 } } }, { _id: 0, judul: 1, diskon: 1 })
db.buku.find({ diskon: { $gt: 20 } }, { _id: 0, judul: 1, diskon: 1 })

Output yang Dihasilkan:

text
[
  { judul: 'Laut Bercerita', diskon: [ 10, 50 ] },
  { judul: 'Bumi', diskon: [ 5, 25 ] },
  { judul: 'Filosofi Teras', diskon: [ 0, 15, 30 ] },
  { judul: 'Atomic Habits', diskon: [ 40, 60 ] }
]
[
  { judul: 'Laut Bercerita', diskon: [ 10, 50 ] },
  { judul: 'Bumi', diskon: [ 5, 25 ] },
  { judul: 'Filosofi Teras', diskon: [ 0, 15, 30 ] },
  { judul: 'Atomic Habits', diskon: [ 40, 60 ] },
  { judul: 'Rumah Kaca', diskon: 22 }
]

Pada empat document yang menyimpan array, hasilnya sama persis — masing-masing punya elemen di atas 20. Bedanya kembali pada Rumah Kaca: bentuk telanjang menangkap skalar 22, $elemMatch tidak. Kesimpulannya: gunakan $elemMatch hanya ketika ada beberapa kondisi yang harus menempel pada elemen yang sama; untuk satu kondisi, operator telanjang lebih sederhana dan mencakup skalar.

Kombinasi dengan Kondisi Lain #

$elemMatch menempati posisi nilai field seperti operator lain, sehingga bebas digabung dengan syarat field berbeda dalam satu filter. Buku nonfiksi yang riwayat diskonnya memuat angka di antara 10 sampai 45 (eksklusif):

js
db.buku.find({ kategori: "nonfiksi", diskon: { $elemMatch: { $gt: 10, $lt: 45 } } }, { _id: 0, judul: 1, diskon: 1 })

Output yang Dihasilkan:

text
[
  { judul: 'Filosofi Teras', diskon: [ 0, 15, 30 ] },
  { judul: 'Atomic Habits', diskon: [ 40, 60 ] }
]

Filosofi Teras lolos lewat angka 15 dan 30, Atomic Habits lewat 40. Isi $elemMatch juga menerima operator lain — $in, $ne, $mod — dengan syarat yang sama: seluruhnya harus dipenuhi elemen yang sama.

Performa: Batas Index yang Rapat #

Dari sisi index, kedua bentuk tadi tidak setara. Pada multikey index — index atas field array, dibahas penuh di Chapter Multikey Index — query rentang ganda tanpa $elemMatch hanya bisa memakai satu batas untuk memindai, misalnya [20, ∞), lalu menyisakan syarat kedua sebagai filter yang dijalankan setelah document dibaca. Bentuk $elemMatch memungkinkan server mengiriskan kedua batas menjadi rentang rapat [20, 35] langsung di dalam index, karena server tahu keduanya menuntut elemen yang sama — lebih sedikit kunci yang disentuh, tanpa penyaringan ulang.

Ketika pertanyaan bisnis sebenarnya berbunyi “diskon tertinggi yang pernah diberikan minimal 20%”, ada pola yang lebih murah dari keduanya: simpan agregatnya sebagai field biasa saat menulis — diskonTertinggi: 50 — lalu query { diskonTertinggi: { $gte: 20 } } dengan index single-field biasa. Field skalar seperti ini dijawab index tanpa semantik array apa pun. $elemMatch tetap alat yang tepat ketika syaratnya memang harus menempel pada satu elemen; untuk pertanyaan tentang keseluruhan array, precompute adalah desain yang lebih hemat. Kita mengukur perbedaan semuanya dengan explain() di Chapter Dasar Performa Query.

Rangkuman #

  • Operator rentang ganda pada field array dipenuhi elemen berbeda — sumber positif palsu klasik; $elemMatch mengunci seluruh kondisi pada satu elemen yang sama.
  • $elemMatch hanya mencocokkan field array; nilai skalar pada field yang sama selalu terlewat — normalisasi tipe saat menulis mencegahnya.
  • $elemMatch satu kondisi setara operator telanjang pada array; gunakan operator telanjang agar sederhana dan mencakup skalar.
  • Pada multikey index, $elemMatch menghasilkan batas rentang yang rapat; pertanyaan agregat lebih baik dijawab field precompute seperti diskonTertinggi.