Di balik setiap find() dan aggregate() yang kita jalankan ada satu objek yang bekerja diam-diam: cursor. Selama ini kita memperlakukannya seperti list yang bisa di-loop, padahal cursor adalah mesin pengambilan bertahap — data diunduh per batch, hanya saat diminta. Memahami perilaku itu menentukan aplikasi kita hemat memori atau sebaliknya; dan pagination halaman katalog adalah kasus nyata yang paling terasa dampaknya. Chapter ini membedah mekanisme cursor PyMongo, lalu membangun dua pagination: cara murah skip/limit dan cara efisien keyset.

Cursor yang Malas #

Berkas berikut memperlihatkan lima perilaku cursor dalam satu program: pembuatan tanpa pengunduhan, pengambilan per batch, iterasi, materialisasi, dan penutupan otomatis:

python
import os

from pymongo import MongoClient

URI = os.environ.get(
    "MONGODB_URI", "mongodb://127.0.0.1:27017/?directConnection=true"
)

client = MongoClient(URI, serverSelectionTimeoutMS=5000)
client.drop_database("latihan_cursor_py")
db = client["latihan_cursor_py"]

db.buku.insert_many([
    {"judul": "Laut Bercerita", "penulis": "Leila S. Chudori", "harga": 98000},
    {"judul": "Bumi", "penulis": "Tere Liye", "harga": 89000},
    {"judul": "Filosofi Teras", "penulis": "Henry Manampiring", "harga": 89000},
    {"judul": "Sapiens", "penulis": "Yuval Noah Harari", "harga": 135000},
    {"judul": "Atomic Habits", "penulis": "James Clear", "harga": 105000},
])

# 1) find() tidak mengunduh apa pun — iterasi yang memicu pengambilan data
kursor = db.buku.find({}, {"judul": 1, "_id": 0})
print("cursor dibuat, data belum diunduh")

# 2) Data diambil per batch; batch_size() mengatur ukurannya
kursor_batch = db.buku.find({}, {"judul": 1, "_id": 0}).batch_size(2)
print("retrieved sebelum next():", kursor_batch.retrieved)
next(kursor_batch)
print("retrieved setelah next():", kursor_batch.retrieved)
kursor_batch.close()

# 3) Iterasi penuh dengan loop for
print("iterasi for:")
for dok in kursor:
    print("  ", dok["judul"])

# 4) Materialisasi seluruh hasil menjadi list
semua = list(db.buku.find({}, {"judul": 1, "_id": 0}))
print("jumlah setelah list():", len(semua))

# 5) Context manager menutup cursor otomatis
with db.buku.find({}, {"judul": 1, "_id": 0}) as cur:
    print("judul pertama:", next(cur)["judul"])
print("cursor tertutup otomatis oleh with")

client.drop_database("latihan_cursor_py")
client.close()

Output yang Dihasilkan:

text
cursor dibuat, data belum diunduh
retrieved sebelum next(): 0
retrieved setelah next(): 2
iterasi for:
   Laut Bercerita
   Bumi
   Filosofi Teras
   Sapiens
   Atomic Habits
jumlah setelah list(): 5
judul pertama: Laut Bercerita
cursor tertutup otomatis oleh with

Mari kita bedah perilaku-perilaku kuncinya:

  1. kursor = db.buku.find(...) — tidak ada satu byte data pun yang berpindah; baris ini hanya menyiapkan query. Baris print berikutnya berjalan seketika, bukti tidak ada round-trip ke server.
  2. .batch_size(2) dan retrieved — data diunduh per batch. Secara bawaan batch pertama berisi 101 document, lalu batch berikutnya diambil lewat perintah getMore sesuai kebutuhan. Atribut retrieved memperlihatkan jumlah yang sudah tiba di memori: setelah satu next(), seluruh batch (2 document) sudah terunduh walau baru satu yang kita pakai.
  3. Loop for — bentuk paling alami; cursor adalah iterator Python. Perhatikan bahwa kursor tetap perawan ketika kursor_batch diuji di tengah program — dua objek cursor tidak saling memengaruhi.
  4. list(...) — materialisasi sekaligus: praktis untuk data kecil, berbahaya untuk jutaan document karena semuanya masuk memori sekaligus.
  5. with db.buku.find() as cur: — cursor PyMongo mendukung context manager; keluar dari blok berarti cursor ditutup walau belum habis. cursor.close() manual berfungsi sama, berguna saat loop kita berhenti di tengah jalan (misalnya break setelah menemukan yang dicari) supaya sisa cursor di server tidak menggantung.

Satu sifat terakhir yang penting: cursor itu sekali jalan. Setelah tuntas diiterasi, mengulang loop yang sama tidak menghasilkan apa-apa — jalankan query baru, atau panggil rewind() untuk mengulang dari awal.

Pagination skip/limit vs Keyset #

Katalog dengan delapan judul kita bagi tiga per halaman. File kedua membandingkan dua teknik:

python
import os

from pymongo import MongoClient

URI = os.environ.get(
    "MONGODB_URI", "mongodb://127.0.0.1:27017/?directConnection=true"
)

client = MongoClient(URI, serverSelectionTimeoutMS=5000)
client.drop_database("latihan_cursor_py")
db = client["latihan_cursor_py"]

db.buku.insert_many([
    {"judul": "Laut Bercerita", "penulis": "Leila S. Chudori", "harga": 98000},
    {"judul": "Bumi", "penulis": "Tere Liye", "harga": 89000},
    {"judul": "Filosofi Teras", "penulis": "Henry Manampiring", "harga": 89000},
    {"judul": "Sapiens", "penulis": "Yuval Noah Harari", "harga": 135000},
    {"judul": "Atomic Habits", "penulis": "James Clear", "harga": 105000},
    {"judul": "Cantik Itu Luka", "penulis": "Eka Kurniawan", "harga": 95000},
    {"judul": "Orang-Orang Bloomington", "penulis": "Budi Darma", "harga": 80000},
    {"judul": "Homecoming", "penulis": "Tere Liye", "harga": 99000},
])

UKURAN_HALAMAN = 3

def halaman_keyset(kunci_terakhir):
    if kunci_terakhir is None:
        filter_ = {}
    else:
        filter_ = {"_id": {"$gt": kunci_terakhir}}
    return list(db.buku.find(filter_).sort("_id", 1).limit(UKURAN_HALAMAN))

print("== Pagination keyset (filter _id > kunci terakhir) ==")
kunci = None
nomor = 1
while True:
    batch = halaman_keyset(kunci)
    if not batch:
        break
    print(f"Halaman {nomor}:", [b["judul"] for b in batch])
    kunci = batch[-1]["_id"]
    nomor += 1

print("== Pagination skip/limit (halaman 3) ==")
batch = list(db.buku.find().sort("_id", 1).skip(6).limit(UKURAN_HALAMAN))
print("Halaman 3:", [b["judul"] for b in batch])

client.drop_database("latihan_cursor_py")
client.close()

Output yang Dihasilkan:

text
== Pagination keyset (filter _id > kunci terakhir) ==
Halaman 1: ['Laut Bercerita', 'Bumi', 'Filosofi Teras']
Halaman 2: ['Sapiens', 'Atomic Habits', 'Cantik Itu Luka']
Halaman 3: ['Orang-Orang Bloomington', 'Homecoming']
== Pagination skip/limit (halaman 3) ==
Halaman 3: ['Orang-Orang Bloomington', 'Homecoming']

Kedua teknik menghasilkan halaman 3 yang sama, tetapi jalannya sangat berbeda:

  1. Keyset — setiap halaman adalah query baru: “berikan 3 document dengan _id lebih besar dari kunci halaman sebelumnya”. Server memakai index _id_ untuk langsung melompat ke posisi kunci; biayanya konstan di halaman ke-1 maupun ke-10.000.
  2. skip/limit — .skip(6).limit(3) memaksa server membaca 6 document lalu membuangnya demi mengirim 3. Semakin dalam halaman, semakin banyak yang dibuang.
  3. Fungsi halaman_keyset — baris kunci = batch[-1]["_id"] menyimpan kunci terakhir untuk query berikutnya; _id yang unik dan terurut membuat halaman tidak pernah menampilkan document dua kali, bahkan bila ada data baru masuk di antara klik halaman.

Keyset di atas mengurutkan berdasarkan _id. Bila urutan yang diinginkan field lain (misalnya harga), kunci pagination menjadi pasangan (harga, _id): urutkan dengan .sort([("harga", 1), ("_id", 1)]) dan saring dengan $gt pada kedua field tersebut agar tetap unik — pola yang sama, hanya kuncinya majemuk.

Rangkuman #

  • find() mengembalikan cursor lazy: data baru diunduh saat iterasi, per batch (bawaan batch pertama 101 document; batch_size() mengaturnya).
  • list(cursor) memindahkan seluruh hasil ke memori — hanya untuk data kecil.
  • Tutup cursor lebih awal dengan cursor.close() atau with ketika loop berhenti di tengah; cursor sekali jalan, rewind() mengulang dari awal.
  • skip/limit sederhana tetapi mahal di halaman dalam; keyset (_id > kunci terakhir + sort + limit) berbiaya konstan dan stabil terhadap data baru.
  • Untuk sort field lain, pakai kunci majemuk (field, _id) demi keunikan posisi.

Cursor sudah tak lagi misterius. Chapter berikutnya mengangkat jaminan atomisitas lintas document: transaksi dari Python.