Re-Parent Satu Kategori, Tree-nya Ikut Nyiklus
Validasi parent yang cuma ngecek self-parent itu ilusi: re-parent ke keturunan sendiri diam-diam bikin siklus, dan MySQL 5.7 nggak punya CTE cycle avoidance.
Ringkasan
Mindahin A jadi anak B bikin siklus karena B itu anaknya A, validasi lama cuma cek parent bukan diri sendiri. Sekarang validateCategory manjat ngecek leluhur satu-satu pakai GetCategoryByID dan errors.Is sampai root, kalau id sama ya ditolak. Biar gak muter terus kalau data lama udah rusak, jalurnya dibatasin cuma sepuluh level aja.
Dua dropdown. Kategori A, terus parent-nya ganti ke kategori B. Save. Form nge-proxy error validasi, dan di log muncul pesan baru yang minggu lalu belum pernah saya lihat: perubahan induk membentuk siklus kategori. Padahal yang saya lakukan cuma mindahin A jadi anak B, kayak mindahin folder di file manager.
Kejadian ini aslinya bukan bug yang kelihatan. validateCategory udah ngecek dua hal yang kelihatannya tepat: parent_id nggak boleh sama dengan id kategori itu sendiri, dan parent kandidat harus ada di database. Dua-duanya lolos buat kasus A di bawah B. Yang nggak kecek: B ternyata anak dari A. Begitu A dikasih parent B, rantainya muter, A ke B, B balik lagi ke A.
parent_id itu linked list nyamar
Model data yang cuma pegang parent_id per baris itu sebenernya linked list yang nyamar jadi tree. Nggak ada constraint database yang melarang dua baris saling tunjuk. MySQL 8.0 bahkan nyediain recursive CTE dengan cycle avoidance buat ngurusi kasus ini di level query [9], tapi database produksi proyek ini masih 5.7, fitur itu nggak ada. Guard-nya harus jalan di layer aplikasi.
Jadi validateCategory diganti: dari kandidat parent, fungsi jalan naik lewat GetCategoryByID berulang-ulang, ngikutin parent_id tiap leluhur. Dua pintu berhentinya jelas: kalau ketemu ErrCategoryNotFound, rantai putus dan parent kandidat nggak sah [12]. Kalau leluhur nggak punya parent lagi, kita udah di root yang sah dan aman. Dan di tengah jalan, kalau ada leluhur yang id-nya sama dengan kategori yang lagi diedit, itu dia siklusnya, ditolak dengan ErrValidation.
ancestorID := *req.ParentID
for i := 0; i < maxCategoryDepth; i++ {
parent, err := s.repo.GetCategoryByID(ctx, ancestorID)
if errors.Is(err, ErrCategoryNotFound) {
return ErrValidation // parent kandidat nggak sah
}
if parent.ID == excludeID {
return ErrValidation // siklus: leluhur = kategori yang diedit
}
if !parent.ParentID.Valid {
break // sampai root yang sah
}
ancestorID = parent.ParentID.Int64
}Perhatikan pemeriksaannya pakai errors.Is, bukan ==. Sejak Go 1.13, error bisa membungkus error lain dan errors.Is menelusuri rantai pembungkusnya sampai ketemu [11]. Error mapping yang nggak pakai itu bisa bikin rantai putus di tempat yang salah.
Depth cap buat data yang udah rusak duluan
Bagian yang paling gampang kelewat: gimana kalau data legacy di database udah nyiklus sebelum guard ini ada? Naik rantai leluhur bakal muter selamanya, dan validasi yang harusnya nyegah siklus malah jadi loop tanpa ujung. Makanya walk-nya dibatasi maxCategoryDepth = 10. Angkanya jauh di atas kedalaman kategori normal, tapi pendek cukup buat gagal cepat.
Satu test nge-gate tiga kasus: re-parent nyiklus ditolak, self-parent tetap ditolak, dan re-parent legal (B tetap di bawah A) tetap lolos. Tanpa yang ketiga, gampang banget guard baru malah ngeblokir operasi sehari-hari.
Sekarang kalau saya mindahin kategori di CMS dan tercekat, yang muncul pesan validasi yang jelas, bukan tree yang ilang. Guard satu tingkat emang kelihatan cukup, sampai suatu hari nggak.
Sumber