Error database django.db.utils.ProgrammingError: more than one row returned by a subquery used as an expression sering muncul tiba-tiba di production environment. Masalah ini terjadi ketika ekspresi Subquery() di dalam annotate() menghasilkan lebih dari satu baris data (cardinality violation). Artikel ini membahas diagnosis teknis, penyebab kegagalan lolos dari unit testing, implementasi perbaikan deterministik, dan pola arsitektur alternatif.
Anatomi Masalah: Lolos Testing, Gagal di Production
Pola bug ini memiliki karakteristik khusus: pipeline CI/CD berstatus hijau dan fitur berjalan normal di lingkungan pengujian staging. Namun, endpoint menghasilkan HTTP 500 beberapa waktu setelah rilis ke production.
Penyebab disparitas ini terletak pada desain test fixture. Pada environment testing, data relasi sering kali dibuat secara minimalis (satu record induk hanya memiliki satu record anak). Selama kardinalitas relasi 1:1 terpenuhi secara de facto di database, query tidak menimbulkan error. Saat volume data production meningkat dan satu record induk memiliki dua atau lebih record anak, engine SQL menolak eksekusi query.
Root Cause: Pelanggaran Skalar SQL
Dalam relational database management system (RDBMS) seperti PostgreSQL, ekspresi di dalam klausul SELECT (yang diabstraksikan oleh annotate() di Django) harus bernilai skalar (tepat satu baris dan satu kolom per baris tabel utama).
Perhatikan model berikut:
from django.db import models
class Customer(models.Model):
name = models.CharField(max_length=255)
class Order(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="orders")
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
Berikut contoh implementasi query yang bermasalah:
from django.db.models import OuterRef, Subquery
# Query yang rentan: mengekstrak nilai total_amount tanpa membatasi baris
latest_order_subquery = Order.objects.filter(
customer=OuterRef("pk")
).values("total_amount")
customers = Customer.objects.annotate(
latest_order_amount=Subquery(latest_order_subquery)
)
ORM mengompilasi query di atas menjadi SQL berikut:
SELECT customer.id,
customer.name,
(SELECT U0.total_amount
FROM order U0
WHERE U0.customer_id = customer.id) AS latest_order_amount
FROM customer;
Jika satu customer memiliki lebih dari satu baris di tabel order, subquery akan mengembalikan multiple rows. Engine SQL langsung membatalkan eksekusi query karena tidak dapat memetakan sebuah himpunan data ke dalam satu field skalar.
Panduan Perbaikan Langkah-demi-Langkah
1. Reproduksi Bug dengan Fixture Multirecord
Langkah pertama adalah membuat test case yang memicu bug dengan menyimulasikan data relasi jamak:
from django.test import TestCase
from django.db.utils import ProgrammingError
from .models import Customer, Order
class CustomerQueryTestCase(TestCase):
def test_subquery_fails_with_multiple_orders(self):
customer = Customer.objects.create(name="Acme Corp")
Order.objects.create(customer=customer, total_amount=100.00)
Order.objects.create(customer=customer, total_amount=250.00)
with self.assertRaises(ProgrammingError):
# Evaluasi QuerySet dengan memanggil list()
list(Customer.objects.annotate(
latest_order_amount=Subquery(
Order.objects.filter(customer=OuterRef("pk")).values("total_amount")
)
))
2. Memperbaiki Query: Ordering Eksplisit dan Slicing [:1]
Untuk menjadikan subquery skalar valid, ada dua syarat mutlak:
- Limit 1: Memastikan subquery maksimal mengembalikan satu baris menggunakan Python slice
[:1]. - Order By Eksplisit: Menjamin konsistensi dan determinisme baris mana yang diambil jika terdapat lebih dari satu record.
latest_order_subquery = Order.objects.filter(
customer=OuterRef("pk")
).order_by("-created_at").values("total_amount")[:1]
customers = Customer.objects.annotate(
latest_order_amount=Subquery(latest_order_subquery)
)
Penting: Jangan gunakan slicing[:1]tanpa klausul.order_by(). Tanpa pengurutan eksplisit, database akan mengembalikan baris secara non-deterministik tergantung pada physical storage scan atau index traversal, yang dapat menghasilkan data inkonsisten antar request.
Alternatif Arsitektur: Jika Membutuhkan Seluruh Data Relasi
Jika tujuannya bukan hanya mengambil record tunggal melainkan mengonsolidasi seluruh data anak, pendekatan Subquery() skalar bukan solusi yang tepat. Gunakan alternatif berikut:
1. PostgreSQL ArrayAgg
Jika menggunakan PostgreSQL dan perlu mengambil seluruh ID atau nominal pesanan dalam format list, gunakan agregasi native array:
from django.contrib.postgres.aggregates import ArrayAgg
customers = Customer.objects.annotate(
all_order_amounts=ArrayAgg("orders__total_amount")
)
2. FilteredRelation (Django 2.0+)
Jika perlu melakukan join bersyarat untuk kalkulasi agregat (seperti total order berstatus sukses), gunakan FilteredRelation yang lebih efisien dalam hal plan query SQL dibanding subquery berkorelasi:
from django.db.models import FilteredRelation, Q, Sum
customers = Customer.objects.annotate(
paid_orders=FilteredRelation(
"orders",
condition=Q(orders__status="PAID")
)
).annotate(
total_paid=Sum("paid_orders__total_amount")
)
Menulis Regression Test untuk Kardinalitas Query
Pastikan query yang telah diperbaiki dilindungi oleh regression test untuk mencegah regresi di masa depan:
class CustomerQueryRegressionTestCase(TestCase):
def test_annotated_latest_order_amount_is_deterministic(self):
customer = Customer.objects.create(name="Beta LLC")
Order.objects.create(customer=customer, total_amount=50.00)
latest = Order.objects.create(customer=customer, total_amount=150.00)
annotated_customer = Customer.objects.annotate(
latest_order_amount=Subquery(
Order.objects.filter(customer=OuterRef("pk"))
.order_by("-created_at")
.values("total_amount")[:1]
)
).get(pk=customer.pk)
self.assertEqual(annotated_customer.latest_order_amount, latest.total_amount)
Kombinasi antara slicing [:1], klausul order_by() deterministik, serta cakupan regression test berbasis multi-record menutup celah runtime error ini di level basis data dan application logic.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!