Composite Primary Keys di Django 5.2: Panduan Praktis dan Batasannya

Pelajari CompositePrimaryKey di Django 5.2, tuple pk, batasan relasi, workaround, dan kapan fitur ini cocok untuk model kamu.

Saat Satu Primary Key Tidak Cukup

Selama ini, model Django biasanya punya satu kolom id sebagai primary key. Pola itu sederhana dan cocok untuk sebagian besar aplikasi.

Namun, tidak semua tabel membutuhkan identifier buatan. Tabel legacy bisa sudah memiliki primary key yang terdiri dari beberapa kolom. Join table juga sering secara alami diidentifikasi oleh pasangan nilai, misalnya product_id dan order_id.

Django 5.2 memperkenalkan CompositePrimaryKey. Fitur ini memungkinkan kamu mendefinisikan primary key yang terdiri dari beberapa field langsung di model Django.

Tapi dukungannya belum menyeluruh. Composite primary key sudah bisa dipakai untuk beberapa operasi ORM inti, tetapi masih memiliki batasan penting pada relations, admin, forms, dan database functions.

Apa Itu Composite Primary Key?

Composite primary key adalah primary key yang dibentuk dari lebih dari satu field. Kombinasi nilai field tersebut harus mengidentifikasi satu baris secara unik.

Contohnya, satu item dalam order bisa diidentifikasi oleh kombinasi produk dan order:

(product_id, order_id)

Tidak ada satu nilai tunggal yang menjadi identifier. Yang menjadi identifier adalah kombinasi keduanya.

Django 5.2 menyediakan CompositePrimaryKey untuk mendefinisikan pola ini:

from django.db import models


class Product(models.Model):
    name = models.CharField(max_length=100)


class Order(models.Model):
    reference = models.CharField(max_length=20, primary_key=True)


class OrderLineItem(models.Model):
    pk = models.CompositePrimaryKey("product_id", "order_id")

    product = models.ForeignKey(
        Product,
        on_delete=models.CASCADE,
    )
    order = models.ForeignKey(
        Order,
        on_delete=models.CASCADE,
    )
    quantity = models.IntegerField()

Dengan definisi tersebut, Django akan membuat composite primary key yang terdiri dari product_id dan order_id saat tabel dibuat.

Perhatikan bahwa pk di sini bukan kolom database tambahan. Ia adalah virtual field yang merepresentasikan gabungan beberapa field.

Kapan Composite Primary Key Masuk Akal?

Composite primary key biasanya relevan dalam beberapa kondisi berikut:

  • Kamu sedang memetakan tabel legacy yang sudah memiliki composite primary key.
  • Data memang secara natural diidentifikasi oleh beberapa field.
  • Kamu membuat join table yang identitasnya adalah kombinasi dua atau lebih entitas.
  • Kamu ingin mempertahankan struktur database yang sudah ada tanpa menambahkan surrogate id.
  • Kamu memiliki tabel log atau event yang secara alami menggunakan kombinasi identifier dan waktu sebagai identitas baris.

Misalnya, sebuah tabel event buku dapat menggunakan kombinasi book_id dan timestamp sebagai primary key. Dalam kasus seperti ini, menambahkan kolom id belum tentu memberikan makna tambahan pada data.

Namun, composite primary key bukan default baru untuk semua model. Untuk sebagian besar model aplikasi, satu primary key tetap lebih sederhana untuk digunakan bersama seluruh fitur Django.

Membaca dan Membuat Object dengan Composite Primary Key

Nilai pk pada model dengan composite primary key direpresentasikan sebagai tuple.

product = Product.objects.create(name="Apple")
order = Order.objects.create(reference="A755H")

item = OrderLineItem.objects.create(
    product=product,
    order=order,
    quantity=1,
)

print(item.pk)
# (1, "A755H")

Urutan nilai dalam tuple mengikuti urutan field yang diberikan ke CompositePrimaryKey:

pk = models.CompositePrimaryKey("product_id", "order_id")

Jadi, nilai item.pk berbentuk:

(product_id, order_id)

Kamu juga dapat mengisi pk secara langsung. Django akan mengisi field yang terkait dengan nilai tuple tersebut:

item = OrderLineItem(
    pk=(2, "B142C"),
)

print(item.pk)
# (2, "B142C")

print(item.product_id)
# 2

print(item.order_id)
# B142C

Pola ini berguna ketika kamu sudah memiliki identifier lengkap dan ingin membuat instance tanpa mengisi setiap field foreign key secara terpisah.

Untuk membuat object baru, kamu tetap perlu mengisi field lain yang wajib:

item = OrderLineItem.objects.create(
    pk=(2, "B142C"),
    quantity=3,
)

print(item.product_id)
# 2

print(item.order_id)
# B142C

Filtering dengan Tuple

Filtering berdasarkan composite primary key juga menggunakan tuple:

item = OrderLineItem.objects.filter(
    pk=(1, "A755H"),
).first()

Untuk menghitung jumlah object:

count = OrderLineItem.objects.filter(
    pk=(1, "A755H"),
).count()

Kamu juga dapat memakai field penyusun primary key secara langsung:

items = OrderLineItem.objects.filter(
    product_id=1,
    order_id="A755H",
)

Kedua pendekatan ini memiliki maksud yang sama. Gunakan filtering berdasarkan pk ketika kamu bekerja dengan identifier gabungan sebagai satu unit. Gunakan field individual ketika query membutuhkan kondisi tambahan atau ingin lebih eksplisit.

Contoh query lengkap:

items = OrderLineItem.objects.filter(
    pk=(1, "A755H"),
    quantity__gte=2,
)

Menggunakan Tuple untuk Assignment

Karena pk adalah gabungan field, assignment pada pk akan mengubah field penyusunnya:

item = OrderLineItem(
    product_id=1,
    order_id="A755H",
    quantity=1,
)

item.pk = (2, "B142C")

print(item.product_id)
# 2

print(item.order_id)
# B142C

Tetap perhatikan makna assignment tersebut. Mengubah pk berarti mengubah identifier object. Pada database yang sudah berisi data, operasi save setelah perubahan ini perlu dipahami sebagai perubahan primary key, bukan sekadar perubahan field biasa.

Untuk kode yang menerima identifier dari API atau input lain, validasi bentuk tuple tetap menjadi tanggung jawab aplikasi:

def find_line_item(pk_value):
    if not isinstance(pk_value, tuple) or len(pk_value) != 2:
        raise ValueError("Composite primary key harus berupa tuple berisi dua nilai")

    product_id, order_id = pk_value

    return OrderLineItem.objects.get(
        product_id=product_id,
        order_id=order_id,
    )

Membuat Kode yang Mengenali Composite Primary Key

Sebelum Django mendukung composite primary key, kode generic sering mencari field dengan primary_key=True.

pk_field = None

for field in Product._meta.get_fields():
    if field.primary_key:
        pk_field = field
        break

Pola tersebut tidak cukup untuk model dengan composite primary key. Field penyusun composite primary key tidak mengandalkan primary_key=True secara individual.

Gunakan _meta.pk_fields:

Product._meta.pk_fields
# [<django.db.models.fields.AutoField: id>]

OrderLineItem._meta.pk_fields
# [
#     <django.db.models.fields.ForeignKey: product>,
#     <django.db.models.fields.ForeignKey: order>,
# ]

Jika kamu menulis reusable app, serializer, atau utility yang bekerja dengan berbagai model Django, gunakan _meta.pk_fields untuk mengetahui field penyusun primary key.

def get_primary_key_values(instance):
    return tuple(
        getattr(instance, field.attname)
        for field in instance._meta.pk_fields
    )

Untuk OrderLineItem, hasilnya akan mewakili nilai product_id dan order_id.

Composite Primary Key adalah Virtual Field

CompositePrimaryKey tidak merepresentasikan satu kolom database. Ia adalah virtual field yang menggabungkan beberapa field nyata.

Konsekuensinya terlihat pada ModelForm.

from django import forms


class OrderLineItemForm(forms.ModelForm):
    class Meta:
        model = OrderLineItem
        fields = "__all__"

Form tersebut tidak akan memiliki field pk. Field composite primary key dikecualikan karena pk bukan satu kolom database yang bisa dirender sebagai satu input form.

Jika kamu mencoba menjadikan pk sebagai field form secara eksplisit, Django akan menolak nama field tersebut.

class OrderLineItemForm(forms.ModelForm):
    class Meta:
        model = OrderLineItem
        fields = ["pk", "quantity"]

Untuk membuat form, gunakan field penyusunnya secara terpisah:

class OrderLineItemForm(forms.ModelForm):
    class Meta:
        model = OrderLineItem
        fields = [
            "product",
            "order",
            "quantity",
        ]

Atau, jika identifier diterima dari URL atau API, proses tuple tersebut di luar ModelForm, lalu gunakan nilainya saat mengambil atau membuat object.

Batasan Relations

Django 5.2 belum mendukung relationship fields yang menunjuk ke model dengan composite primary key.

Contoh berikut tidak didukung:

class Shipment(models.Model):
    item = models.ForeignKey(
        OrderLineItem,
        on_delete=models.CASCADE,
    )

ForeignKey mengharapkan target dengan satu primary key. Model OrderLineItem memiliki primary key yang terdiri dari product_id dan order_id, sehingga pola ini belum dapat digunakan.

Batasan ini juga mencakup:

  • ForeignKey
  • ManyToManyField
  • generic relations
  • GenericForeignKey
  • Django admin untuk model dengan composite primary key

Model dengan composite primary key belum dapat didaftarkan di Django admin.

Workaround dengan ForeignObject

Untuk kasus tertentu, Django menyediakan ForeignObject sebagai workaround:

class Shipment(models.Model):
    item_order_id = models.CharField(max_length=20)
    item_product_id = models.IntegerField()

    item = models.ForeignObject(
        OrderLineItem,
        on_delete=models.CASCADE,
        from_fields=(
            "item_order_id",
            "item_product_id",
        ),
        to_fields=(
            "order_id",
            "product_id",
        ),
    )

ForeignObject memetakan beberapa field lokal ke beberapa field pada model target. Namun, ada trade-off penting:

  • ForeignObject tidak membuat kolom baru seperti item_id.
  • ForeignObject tidak membuat foreign key constraint di database.
  • ForeignObject tidak membuat index secara otomatis.
  • Nilai on_delete diabaikan.
  • ForeignObject adalah internal API dan tidak tercakup oleh deprecation policy Django.

Artinya, workaround ini tidak setara dengan ForeignKey. Kamu harus mengelola integritas referensi, index, dan perilaku penghapusan dengan lebih hati-hati.

Gunakan pendekatan ini hanya jika kamu memahami konsekuensinya dan memang membutuhkan relation-like behavior di level ORM.

Batasan Database Functions

Banyak database functions hanya menerima satu expression. Composite primary key terdiri dari beberapa column expressions, sehingga tidak semua function dapat menerima pk.

Contoh berikut tidak setara:

from django.db.models import Max


OrderLineItem.objects.aggregate(
    latest=Max("order_id"),
)

Query tersebut bekerja karena order_id adalah satu field.

Sebaliknya, menggunakan composite pk pada function yang hanya menerima satu expression dapat menghasilkan ValueError:

OrderLineItem.objects.aggregate(
    latest=Max("pk"),
)

Count adalah pengecualian yang didukung:

from django.db.models import Count


result = OrderLineItem.objects.aggregate(
    total=Count("pk"),
)

Jangan mengasumsikan bahwa semua aggregation atau database function dapat memperlakukan composite pk seperti field tunggal. Jika function membutuhkan satu expression, gunakan field penyusun yang sesuai atau tulis query dengan bentuk yang didukung database dan Django.

Tidak Bisa Mengubah Primary Key Setelah Tabel Dibuat

Composite primary key perlu dipertimbangkan sejak desain tabel.

Django tidak mendukung migrasi model dari single primary key ke composite primary key setelah tabel dibuat. Sebaliknya, migrasi dari composite primary key kembali ke single primary key juga tidak didukung setelah table creation.

Karena itu, perubahan ini bukan refactor model biasa:

class OrderLineItem(models.Model):
    pk = models.CompositePrimaryKey("product_id", "order_id")

Sebelum memilihnya, periksa:

  • Apakah struktur database memang sudah menggunakan composite primary key?
  • Apakah identifier gabungan tersebut akan tetap stabil?
  • Apakah aplikasi membutuhkan Django admin?
  • Apakah model akan menjadi target ForeignKey atau ManyToManyField?
  • Apakah library pihak ketiga kamu mengasumsikan satu primary key?
  • Apakah API kamu dapat merepresentasikan tuple identifier dengan jelas?

Untuk tabel baru, keputusan ini perlu dibuat sebelum table creation. Untuk tabel existing, lakukan pemeriksaan schema dan compatibility terlebih dahulu, bukan hanya mengubah deklarasi model.

Django 6.0: Ada Kemajuan, tetapi Batasan Inti Masih Ada

Django 6.0 melanjutkan pengembangan dukungan composite primary key. Release notes mencatat improvements untuk penggunaan composite primary key pada raw queries dan subqueries.

Ini membantu beberapa pola query menjadi lebih praktis dibandingkan Django 5.2. Namun, improvements tersebut tidak menghapus batasan utama pada relations.

Pada Django 6.0, kamu tetap tidak boleh menganggap composite primary key sudah setara dengan single primary key dalam seluruh ekosistem Django. Dukungan untuk ForeignKey, ManyToManyField, generic relations, forms, dan Django admin masih memiliki batasan inti.

Jadi, upgrade ke Django 6.0 dapat membantu kebutuhan query tertentu, terutama raw query dan subquery, tetapi bukan alasan untuk mengabaikan evaluasi desain model dan compatibility library.

Serialisasi Composite Primary Key dengan GraphQL dan Strawberry

GraphQL biasanya mengharapkan identifier yang mudah dibawa dalam request dan response. Karena pk pada composite primary key berbentuk tuple, kamu perlu menentukan cara serialisasinya.

Salah satu pendekatan adalah mengirim identifier sebagai object dengan field terpisah:

import strawberry


@strawberry.type
class OrderLineItemNode:
    product_id: int
    order_id: str
    quantity: int

    @classmethod
    def from_model(cls, item):
        product_id, order_id = item.pk

        return cls(
            product_id=product_id,
            order_id=order_id,
            quantity=item.quantity,
        )

Query GraphQL dapat mengembalikan nilai composite identifier secara eksplisit:

@strawberry.type
class Query:
    @strawberry.field
    def order_line_item(
        self,
        product_id: int,
        order_id: str,
    ) -> OrderLineItemNode | None:
        item = (
            OrderLineItem.objects
            .filter(pk=(product_id, order_id))
            .first()
        )

        if item is None:
            return None

        return OrderLineItemNode.from_model(item)

Dengan bentuk ini, client tidak perlu menebak urutan isi tuple. Query menerima dua argument yang jelas:

query {
  orderLineItem(productId: 1, orderId: "A755H") {
    productId
    orderId
    quantity
  }
}

Untuk mutation, pola input-nya dapat dibuat eksplisit juga:

@strawberry.input
class OrderLineItemKeyInput:
    product_id: int
    order_id: str


@strawberry.type
class Mutation:
    @strawberry.mutation
    def update_quantity(
        self,
        key: OrderLineItemKeyInput,
        quantity: int,
    ) -> OrderLineItemNode | None:
        item = (
            OrderLineItem.objects
            .filter(pk=(key.product_id, key.order_id))
            .first()
        )

        if item is None:
            return None

        item.quantity = quantity
        item.save()

        return OrderLineItemNode.from_model(item)

Contoh ini bukan fitur khusus composite primary key dari Strawberry. Ini adalah pola serialisasi di layer GraphQL agar tuple primary key memiliki representasi yang stabil dan mudah dipahami client.

Hindari mengandalkan string gabungan seperti "1:A755H" jika kamu belum menetapkan aturan escaping dan parsing yang jelas. Object dengan field terpisah biasanya lebih eksplisit.

Decision Guide

Gunakan composite primary key jika:

  • Database kamu memang membutuhkan kombinasi beberapa field sebagai identifier.
  • Kamu memetakan tabel legacy yang sudah memiliki composite primary key.
  • Kamu membuat join table yang identitasnya secara alami berupa kombinasi beberapa key.
  • Kamu tidak membutuhkan model tersebut sebagai target ForeignKey atau ManyToManyField.
  • Kamu siap menangani keterbatasan admin, forms, generic relations, dan database functions.
  • Kamu sudah memutuskan struktur tersebut sebelum table creation.

Pertimbangkan single primary key jika:

  • Model akan sering direferensikan oleh model lain.
  • Kamu membutuhkan ForeignKey dan ManyToManyField yang sederhana.
  • Model harus dikelola melalui Django admin.
  • Tim ingin memaksimalkan compatibility dengan library Django pihak ketiga.
  • Tidak ada alasan domain atau database yang kuat untuk memakai composite identifier.
  • Kamu ingin mengubah strategi primary key setelah table creation.

Gunakan ForeignObject hanya jika:

  • Kamu membutuhkan relation-like behavior terhadap composite key.
  • Kamu memahami bahwa database tidak akan mendapatkan foreign key constraint atau index dari field tersebut.
  • Kamu siap mengelola integritas data dan delete behavior sendiri.
  • Ketergantungan pada internal API dapat diterima oleh project kamu.

Kesimpulan

CompositePrimaryKey di Django 5.2 membawa dukungan resmi untuk primary key yang terdiri dari beberapa field. Kamu dapat membuat object dengan tuple, melakukan filtering memakai tuple, melakukan assignment ke pk, dan menggunakan _meta.pk_fields untuk kode yang perlu mengenali penyusun primary key.

Namun, fitur ini bukan pengganti langsung single primary key. Composite primary key masih memiliki batasan pada relations, generic relations, Django admin, forms, dan banyak database functions. ForeignObject dapat menjadi workaround, tetapi tidak membuat constraint atau index dan merupakan internal API.

Django 6.0 menambah improvements pada raw queries dan subqueries, tetapi batasan inti pada relations dan admin masih perlu diperhitungkan.

Sebelum menggunakannya, mulai dari desain database dan kebutuhan aplikasi, bukan dari deklarasi model. Jika kombinasi field memang merupakan identitas alami data atau kamu perlu memetakan legacy schema, CompositePrimaryKey bisa menjadi pilihan yang tepat. Jika tidak, single primary key biasanya memberikan lebih sedikit friction.

Sudah pernah memetakan legacy table atau join table dengan composite primary key? Bagikan pengalaman dan trade-off yang kamu temui di komentar.

Referensi

↑