Query terdekat dan query area yang kita kuasai dua chapter terakhir baru berguna kalau skemanya dirancang benar. “Cabang terdekat” menuntut setiap cabang menjadi document sendiri; “toko mana yang melayani alamat ini” menuntut polygon tersimpan per toko; dan lokasi kurir yang berpindah tiap menit menuntut pola update yang berbeda dari lokasi toko yang statis. Chapter penutup section ini merangkai semuanya menjadi satu rancangan skema jaringan toko buku multi-cabang — struktur diputuskan lebih dulu, index mengikuti pola query-nya.

Peta materi Chapter ini:

  • Pola satu jaringan banyak cabang: document per cabang + referensi induk.
  • Compound index { kategori: 1, lokasi: "2dsphere" } untuk query kategori + terdekat.
  • Area delivery sebagai Polygon per toko, dicari dengan $geoIntersects.
  • Lokasi kurir dinamis: update di tempat, bukan tumpukan riwayat.

Satu Jaringan, Banyak Cabang #

Jalan pintas yang menggoda adalah menyematkan seluruh lokasi cabang sebagai array di document induk. Uji cepat berikut membuktikan jalan itu buntu: index 2dsphere menolak field yang berisi array object GeoJSON.

js
use latihan_geo
db.uji_skema.drop()
db.uji_skema.createIndex({ lokasi: "2dsphere" })
try {
  db.uji_skema.insertOne({ nama: "Toko Buku Nusantara", lokasi: [ { type: "Point", coordinates: [106.83, -6.2] }, { type: "Point", coordinates: [107.62, -6.9] } ] })
} catch (e) {
  print("Galat: " + e.code + " :: " + e.message)
}
db.uji_skema.drop()

Output yang Dihasilkan:

text
switched to db latihan_geo
true
lokasi_2dsphere
Galat: 510 :: Could not extract geo keys at path 'lokasi' for document with _id ObjectId('6ad3ea902f7dc1fb9b892330'): Point must only contain numeric elements, instead got type object

true

Error 510 menegaskan pesannya: Point must only contain numeric elements — satu field lokasi hanya boleh memuat satu geometri, bukan daftar cabang. Rancangan yang benar membalik arahnya: setiap cabang menjadi document penuh di collection cabang, merujuk balik ke induknya lewat jaringanId — pola referensi one-to-many dari Chapter Relasi One-to-Many.

Document induk menyimpan data brand dan subset cabangPopuler untuk halaman depan; seluruh attribute cabang hidup di document cabang sendiri. Kita bangun skemanya:

js
db.jaringan.drop()
db.cabang.drop()
db.jaringan.insertMany([
  { _id: "buku-nusantara", nama: "Toko Buku Nusantara", pusat: "Jakarta", cabangPopuler: ["Menteng", "Dago", "Cikini"] },
  { _id: "buku-senja", nama: "Toko Buku Senja", pusat: "Surabaya", cabangPopuler: ["Tunjungan", "Malioboro"] }
])
db.cabang.insertMany([
  { jaringanId: "buku-nusantara", nama: "Nusantara Menteng", kategori: "buku", kota: "Jakarta", lokasi: { type: "Point", coordinates: [106.83, -6.2] } },
  { jaringanId: "buku-nusantara", nama: "Nusantara Dago", kategori: "buku", kota: "Bandung", lokasi: { type: "Point", coordinates: [107.62, -6.9] } },
  { jaringanId: "buku-nusantara", nama: "Nusantara Cikini", kategori: "atk", kota: "Jakarta", lokasi: { type: "Point", coordinates: [106.84, -6.18] } },
  { jaringanId: "buku-senja", nama: "Senja Tunjungan", kategori: "buku", kota: "Surabaya", lokasi: { type: "Point", coordinates: [112.73, -7.25] } },
  { jaringanId: "buku-senja", nama: "Senja Malioboro", kategori: "kopi", kota: "Yogyakarta", lokasi: { type: "Point", coordinates: [110.37, -7.8] } }
])
db.cabang.createIndex({ jaringanId: 1 })

Output yang Dihasilkan:

text
true
true
{
  acknowledged: true,
  insertedIds: { '0': 'buku-nusantara', '1': 'buku-senja' }
}
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad3ea902f7dc1fb9b892331'),
    '1': ObjectId('6ad3ea902f7dc1fb9b892332'),
    '2': ObjectId('6ad3ea902f7dc1fb9b892333'),
    '3': ObjectId('6ad3ea902f7dc1fb9b892334'),
    '4': ObjectId('6ad3ea902f7dc1fb9b892335')
  }
}
jaringanId_1

Empat keputusan terkandung di skema ini:

  1. Document per cabang: lokasi berada di tingkat atas document cabang — satu geometri per field, persis seperti yang diminta index — dan setiap cabang bisa di-query, di-update, bahkan dihapus tanpa menyentuh induknya.
  2. Referensi jaringanId: pengganti embed; index jaringanId_1 melayani query “semua cabang milik jaringan ini”.
  3. _id bermakna: string "buku-nusantara" menggantikan ObjectId acak — referensi mudah dibaca tanpa query tambahan.
  4. cabangPopuler sebagai subset: daftar nama untuk halaman brand; daftar lengkap tetap hidup di cabang. Ini pola Chapter Subset Pattern — jaringan dengan ratusan cabang pun tetap ramping karena yang di-embed hanya yang sering dibaca.

Compound Index Kategori dan Lokasi #

Aplikasi kita punya dua pertanyaan utama tentang cabang: “toko buku terdekat mana?” (kategori + lokasi) dan “cabang jaringan X yang terdekat mana?” (jaringanId + lokasi). Keduanya filter equality + query geospatial — pola compound index dari Chapter Geospatial Indexes. Kita buat yang pertama, lalu uji:

js
db.cabang.createIndex({ kategori: 1, lokasi: "2dsphere" })
db.cabang.find({ kategori: "buku", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] }, $maxDistance: 200000 } } }, { _id: 0, nama: 1, kategori: 1, kota: 1 })
db.cabang.find({ kategori: "buku", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }).explain().queryPlanner.winningPlan.indexName

Output yang Dihasilkan:

text
kategori_1_lokasi_2dsphere
[
  { nama: 'Nusantara Menteng', kategori: 'buku', kota: 'Jakarta' },
  { nama: 'Nusantara Dago', kategori: 'buku', kota: 'Bandung' }
]
kategori_1_lokasi_2dsphere

Query pertama mengembalikan dua cabang kategori buku dalam radius 200 km dari Menteng, terurut dari terdekat: Nusantara Menteng (0 km) lalu Nusantara Dago (±117 km). Nusantara Cikini (±2,5 km) tersaring oleh kategori: "atk", dan Senja Tunjungan jauh di Surabaya. Baris explain() menegaskan index yang dipakai: kategori_1_lokasi_2dsphere menangani filter equality dan pencarian geo dalam satu tahap.

Pertanyaan kedua kini bisa diuji — dan gagal dengan cara yang mendidik:

js
try {
  db.cabang.find({ jaringanId: "buku-nusantara", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }, { _id: 0, nama: 1, kategori: 1 }).toArray()
} catch (e) {
  print("Galat: " + e.code + " :: " + e.message.split(":: caused by ::").pop().trim())
}
db.cabang.createIndex({ jaringanId: 1, lokasi: "2dsphere" })
db.cabang.find({ jaringanId: "buku-nusantara", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }, { _id: 0, nama: 1, kategori: 1 })

Output yang Dihasilkan:

text
Galat: 291 :: unable to find index for $geoNear query

jaringanId_1_lokasi_2dsphere
[
  { nama: 'Nusantara Menteng', kategori: 'buku' },
  { nama: 'Nusantara Cikini', kategori: 'atk' },
  { nama: 'Nusantara Dago', kategori: 'buku' }
]

Error 291 yang sudah kita kenal muncul lagi: $near menolak berjalan tanpa index geospatial yang cocok — dan jaringanId_1 (tanpa field geo) tidak memenuhi. Setelah index { jaringanId: 1, lokasi: "2dsphere" } berdiri, query yang sama mengembalikan tiga cabang buku-nusantara terurut dari terdekat: Menteng (0 km), Cikini (±2,5 km), Dago (±117 km). Pelajaran pemodelannya: setiap pola query equality + lokasi menuntut compound index-nya sendiri — dua pola query, dua index.

Area Delivery sebagai Polygon #

“Apakah alamat saya bisa diantar?” bukan pertanyaan jarak murni — Dago dan Menteng sama-sama melayani Jakarta Selatan dengan batas wilayah yang tidak bundar. Bentuk yang tepat adalah Polygon per toko, disimpan di collection area_delivery dengan referensi kembali ke cabang. Menanyakan “polygon mana yang memuat titik ini” adalah pekerjaan $geoIntersects — saudara $geoWithin yang menguji perpotongan, bukan kontensi penuh:

js
db.area_delivery.drop()
db.area_delivery.insertMany([
  { toko: "Nusantara Menteng", jaringanId: "buku-nusantara", cakupan: { type: "Polygon", coordinates: [ [[106.80, -6.22], [106.87, -6.22], [106.87, -6.15], [106.80, -6.15], [106.80, -6.22]] ] } },
  { toko: "Nusantara Dago", jaringanId: "buku-nusantara", cakupan: { type: "Polygon", coordinates: [ [[107.55, -6.95], [107.70, -6.95], [107.70, -6.85], [107.55, -6.85], [107.55, -6.95]] ] } }
])
db.area_delivery.createIndex({ cakupan: "2dsphere" })
db.area_delivery.find({ cakupan: { $geoIntersects: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }, { _id: 0, toko: 1 })
db.area_delivery.find({ cakupan: { $geoIntersects: { $geometry: { type: "Point", coordinates: [110.42, -6.97] } } } }, { _id: 0, toko: 1 })

Output yang Dihasilkan:

text
true
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad3ea902f7dc1fb9b892336'),
    '1': ObjectId('6ad3ea902f7dc1fb9b892337')
  }
}
cakupan_2dsphere
[ { toko: 'Nusantara Menteng' } ]

Titik alamat di Menteng [106.83, -6.2] berpotongan dengan cakupan Nusantara Menteng — toko itulah yang melayani alamat tersebut. Titik di Semarang [110.42, -6.97] tidak memotong polygon mana pun, dan cursor kosong memang tidak mencetak apa-apa di mongosh — baris terakhir output di atas milik query Menteng saja. Dengan index 2dsphere pada cakupan, pencarian ini tidak memindai seluruh collection meski area-nya bertambah ratusan. Aturan ring Polygon-nya sama seperti Chapter GeoJSON: ring tertutup, titik pertama diulang di akhir.

Lokasi Kurir yang Selalu Berpindah #

Lokasi kurir adalah kebalikan lokasi toko: umurnya menit, bukan tahun. Pola yang tepat: satu document per kurir, posisi di-update di tempat — bukan ditumpuk sebagai riwayat:

js
db.kurir.drop()
db.kurir.insertOne({ nama: "Budi", status: "aktif", lokasi: { type: "Point", coordinates: [106.82, -6.19] } })
db.kurir.createIndex({ lokasi: "2dsphere" })
db.kurir.updateOne({ nama: "Budi" }, { $set: { lokasi: { type: "Point", coordinates: [106.825, -6.195] } }, $currentDate: { diperbarui: true } })
db.kurir.updateOne({ nama: "Siti" }, { $set: { status: "aktif", lokasi: { type: "Point", coordinates: [106.83, -6.18] } }, $currentDate: { diperbarui: true } }, { upsert: true })
db.kurir.find({ lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] }, $maxDistance: 5000 } } }, { _id: 0, nama: 1, diperbarui: 1 })

Output yang Dihasilkan:

text
true
{
  acknowledged: true,
  insertedId: ObjectId('6ad3ea902f7dc1fb9b892338')
}
lokasi_2dsphere
{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}
{
  acknowledged: true,
  insertedId: ObjectId('6ad3ea919d08d4e3fde1a47d'),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}
[
  { nama: 'Budi', diperbarui: ISODate('2026-10-17T21:37:21.234Z') },
  { nama: 'Siti', diperbarui: ISODate('2026-10-17T21:37:21.246Z') }
]

Mekanismenya empat lapis:

  1. $set menimpa seluruh lokasi: koordinat lama Budi tertimpa koordinat baru — document tetap seukuran tetap, tidak pernah tumbuh.
  2. $currentDate: stempel waktu diperbarui diperbarui server setiap kali posisi berubah — umur data terlihat tanpa menyimpan riwayatnya.
  3. upsert: true: “ping” pertama Siti membuat document-nya langsung — aplikasi kurir tidak perlu membedakan insert dan update.
  4. Query terdekat tetap standar: kurir dalam radius 5 km dari alamat, terurut — Budi (±0,8 km) mendahului Siti (±2,2 km).

Memilih Bentuk Geometri #

Seluruh pola di atas merangkum menjadi satu tabel keputusan — pertanyaan bisnis menentukan bentuk data, dan bentuk data menentukan query:

Kebutuhan bisnisBentuk dataField contohQuery utama
Posisi entitas saat ini (cabang, kurir)Pointlokasi$near, $geoWithin
Cakupan layanan (area delivery, zona kota)Polygoncakupan$geoIntersects, $geoWithin
Jalur tempuh (rute pengiriman harian)LineStringjalur$geoIntersects
Jejak historis per waktu (perjalanan kurir)time series collectionmeasurement + metaFieldwindow function

Tiga baris pertama adalah geometri GeoJSON dari Chapter GeoJSON — rute pengiriman sebagai LineString tinggal masuk ke collection rute dan dicari dengan $geoIntersects saat kita perlu tahu rute mana yang melewati satu titik. Baris keempat bukan geometri, melainkan keputusan collection yang dipicu dimensi waktu. Dengan empat pola ini, aplikasi lokasi jaringan toko buku kita punya tempat untuk semua data geraknya.

Rangkuman #

  • Satu jaringan banyak cabang dimodelkan sebagai document per cabang + referensi jaringanId; array lokasi di document induk ditolak index 2dsphere (error 510).
  • Setiap pola query equality + lokasi butuh compound index-nya sendiri: { kategori: 1, lokasi: "2dsphere" } dan { jaringanId: 1, lokasi: "2dsphere" }.
  • Area delivery disimpan sebagai Polygon per toko; “toko mana yang melayani titik ini” dijawab $geoIntersects.
  • Lokasi kurir dinamis: satu document per kurir, $set menimpa posisi, $currentDate mencatat waktunya, upsert untuk ping pertama — riwayat penuh diserahkan ke time series collection.
  • cabangPopuler di document induk adalah Subset Pattern: embed yang sering dibaca, sisanya tinggal di-refer.

Data lokasi yang berpindah tiap menit membuka babak berikutnya dari Course ini: collection yang dirancang khusus untuk data berorientasi waktu — time series.