Django Polimorfizm (Polymorphism)

Makale

Django Model sınıflarında Polimorfizm

Polimorfizm (Polymorphism) nedir ?

Bir sınıf objesinin bir çok formda (biçimde) olabilmesidir. Bir sistemdeki farklı tipte kullanıcılar(yönetici, normal kullanıcı, misafir kullanıcı gibi.), farklı türde ürünler (kitap, e-kitap, defter, kalem, tablet vs.) örnek olarak gösterilebilir. Sistemdeki her kullanıcı sistemde farklı içerik görmekte, farklı yetkiye sahip olmaktadır. Ya da bir e-ticaret sitesinde farklı türde ürünler alışveriş sepetine eklenebilmektedir.

Poliformizmin çıkardığı zorluklar

Polimorfizmi modellemenin bir çok yolu var. Bazı yaklaşımlar Django ORM'nin standart özelliklerini kullanırken bazıları spesifik özelliklerini kullanır.
Polimorfik modellerin çıkardığı ana zorluklar;

  • Tek bir polimorfik objenin nasıl sunulacağı: Poliformik objelerin farklı nitelikleri olabilir.Django ORM veritabanında bu nitelikleri kolonlara dönüştürerek haritalar. Bu haritalama işlemini nasıl yapmalıdır? Farklı objeler aynı tabloda bulunmalı mıdır? Birden fazla tablo mu olmalıdır?
  • Bir poliformik modelin örneğine(instance) nasıl referans verileceği: Django ORM özelliklerini ve veritabanını verimli şekilde kullanmak için objeleri foreign key'ler ile ilişkilendiriyoruz. İlişkilendirmeyi nasıl yapacağınıza karar vermeniz oldukça önem arzeder.

Örnek bir proje ile polimorfizme farklı yaklaşımları görelim. Kitap satışı yapan basit bir işletmeniz olduğunu düşünelim.

models.py

from django.contrib.auth import get_user_model
from django.db import models


class Book(models.Model):
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )
    weight = models.PositiveIntegerField(
        help_text='in grams',
    )

    def __str__(self) -> str:
        return self.name


class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(Book)
  • Migration yaptıktan sonra shell ortamında veri ekleme gerçekleştirelim.
>>> from app.models import *
>>> Book.objects.create(name='Çöle İnen Nur', price=50, weight=300)
<Book: Çöle İnen Nur>
>>> from django.contrib.auth import get_user_model
>>> User = get_user_model()
>>> u = User.objects.first()
>>> u
<User: adnan>
>>> cart = Cart.objects.create(user=u)
>>> cart.books.add(Book.objects.first())
>>> cart
<Cart: Cart object (1)>
>>> cart.books.all()
<QuerySet [<Book: Çöle İnen Nur>]>
>>> 
  • Buraya kadar polimorfizm ele alınmadı.

Sparse Model

  • Baskılı kitap satışın yapan işletmenizin artık e-kitap sattığını varsayalım. Yeni bir ürün tipi sisteme dahil oldu. Model sınıfını aşağıdaki gibi düzenleyelim.
from django.contrib.auth import get_user_model
from django.db import models


class Book(models.Model):
    TYPE_PHYSICAL = 'physical'
    TYPE_VIRTUAL = 'virtual'
    TYPE_CHOICES = (
        (TYPE_PHYSICAL, 'Physical'),
        (TYPE_VIRTUAL, 'Virtual'),
    )
    type = models.CharField(
        max_length=20,
        choices=TYPE_CHOICES,
        default=TYPE_PHYSICAL
    )

    # Common attributes
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )

    # Specific attributes
    weight = models.PositiveIntegerField(
        help_text='in grams',
    )
    download_link = models.URLField(
        null=True, blank=True,
    )

    def __str__(self) -> str:
        return f'[{self.get_type_display()}] {self.name}'


class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(
        Book,
    )

  • Migration gerçekleştirip shell ortamında biraz çalışalım.
>>> from app.models import *
>>> from django.contrib.auth import get_user_model
>>> User = get_user_model()
>>> u = User.objects.first()
>>> u
<User: adnan>
>>> physical_book = Book.objects.create(
...     type=Book.TYPE_PHYSICAL,
...     name='Yitik Cennet',
...     price=20,
...     weight=100,
...     download_link=None,
... )
>>> physical_book
<Book: [Physical] Yitik Cennet>
>>> virtual_book = Book.objects.create(
...     type=Book.TYPE_VIRTUAL,
...     name='Zamanın Kıymeti',
...     price=30,
...     weight=0,
...     download_link='https://mybooks.com/12345',
... )
>>> virtual_book
<Book: [Virtual] Zamanın Kıymeti>
>>> cart = Cart.objects.first()
>>> cart
<Cart: Cart object (1)>
>>> cart.books.all()
<QuerySet [<Book: [Physical] Çöle İnen Nur>]>
>>> cart.books.clear()
>>> cart.books.all()
<QuerySet []>
>>> cart.books.add(physical_book, virtual_book)
>>> cart.books.all()
<QuerySet [<Book: [Physical] Yitik Cennet>, <Book: [Virtual] Zamanın Kıymeti>]>

  • E-kitaplar için Book model sınıfını düzenledik. Ancak bir problem var. Aşağıdaki örnekleri inceleyelim.
>>> Book.objects.create(
...     type=Book.TYPE_PHYSICAL,
...     name='İlim Yolunda Sabır',
...     price=30,
...     weight=0,
...     download_link='http://books.com/54321',
... )
  • Fiziksel tipte bir kitap olarak kayıt edilmesine rağmen 0 gram ağırlığında ve indirme linki eklenen hatalı bir kitap verisi kaydedilmiş.
>>> Book.objects.create(
...     type=Book.TYPE_VIRTUAL,
...     name='Bu Ülke',
...     price=15,
...     weight=100,
...     download_link=None,
... )
  • Sanal tipte bir kitap olarak kayıt edilmesine rağmen ağırlığı 100 gram olarak kaydedilmiş.
  • Yukarıdaki örneklerdeki hatalı veri kaydını engellemek için validasyon(doğrulama) ihtiyacı vardır.

models.py

from django.core.exceptions import ValidationError


class Book(models.Model):

    # ...

    def clean(self) -> None:
        if self.type == Book.TYPE_VIRTUAL:
            if self.weight != 0:
                raise ValidationError(
                    'A virtual product weight cannot exceed zero.'
                )

            if self.download_link is None:
                raise ValidationError(
                    'A virtual product must have a download link.'
                )

        elif self.type == Book.TYPE_PHYSICAL:
            if self.weight == 0:
                raise ValidationError(
                    'A physical product weight must exceed zero.'
                )

            if self.download_link is not None:
                raise ValidationError(
                    'A physical product cannot have a download link.'
                )

        else:
            assert False, f'Unknown product type "{self.type}"'
  • clean() methodu sadece Django forms tarafından çağırılır. Django form tarafından oluşturulmayan objeler için kendi validasyonlarınızı yazmanız gerekmektedir.
  • Hatalı bir şekilde girilen verilerin validasyonlarını test edelim:
>>> book = Book(
...    type=Book.TYPE_PHYSICAL,
...    name='Yitik Cennet',
...    price=20,
...    weight=0,
...    download_link='http://mybooks.com/54321',
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A physical product weight must exceed zero.']}

>>> book = Book(
...    type=Book.TYPE_VIRTUAL,
...    name='Çöle İnen Nur',
...    price=50,
...    weight=200,
...    download_link=None,
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A virtual product weight cannot exceed zero.']}
  • Book.objects.create(...) diyerek oluşturduğumuz yeni bir kitap veritabanına kaydedilecektir. Veritabanına kaydetmeden önce validasyon yapıp(full_clean()) herhangi bir hata olmama durumuna göre kayıt yapmak gerekmektedir.

NOT: Sparse Model tekniği denormalizasyonun bir ürünüdür. Denormalizasyonda model sınıflarınızda birçok null alanlar bulunur. Veri yazarken performans kaybı olsa da okurken yüksek peformans elde edilir.

Avantajlar:
  • Anlaması ve sürdürülebilir olması kolay.
Dezavantajlar:
  • Veritabanındaki NOT NULL kısıtlamasını etkili bir şekilde kullanamamak. Null değerler tüm obje tipleri için tanımlı olmayan nitelikler(attributes) için kullanılır.
  • Karmaşık validasyon mantığı vardır. Daha fazla test gerektirir.
  • Bir model sınıfında çok fazla Null alan kullanmak karmaşıklığa neden olur.
  • Yeni tip modeller için ekstra alanlar ve validasyonlar gerekli olduğu için veritabanı üzerinde değişiklik yapmakı da gerektirir.

Use Case

  • Sparse model bir çok niteliği(attributes) paylaşan ve yeni niteliklerin çok sık eklenmediği zamanlarda heterojen objeler için idealdir.

Semi Structured Model

models.py

from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import JSONField
from django.db import models

class Book(models.Model):
    TYPE_PHYSICAL = 'physical'
    TYPE_VIRTUAL = 'virtual'
    TYPE_CHOICES = (
        (TYPE_PHYSICAL, 'Physical'),
        (TYPE_VIRTUAL, 'Virtual'),
    )
    type = models.CharField(
        max_length=20,
        choices=TYPE_CHOICES,
    )

    # Common attributes
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )
    # for extra fields
    extra = JSONField()

    def __str__(self) -> str:
        return f'[{self.get_type_display()}] {self.name}'


class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(
        Book,
        related_name='+',
    )
  • Shell ortamında işlemler
>>> from app.models import Book
>>> physical_book = Book(
...     type=Book.TYPE_PHYSICAL,
...     name='Yitik Cennet',
...     price=20,
...     extra={'weight': 100},
... )
>>> physical_book.full_clean()
>>> physical_book.save()
<Book: [Physical] Yitik Cennet>

>>> virtual_book = Book(
...     type=Book.TYPE_VIRTUAL,
...     name='Çöle İnen Nur',
...     price=50,
...     extra={'download_link': 'http://mybooks.com/12345'},
... )
>>> virtual_book.full_clean()
>>> virtual_book.save()
<Book: [Virtual] Çöle İnen Nur>

>>> from semi_structured.models import Cart
>>> cart = Cart.objects.create(user=user)
>>> cart.books.add(physical_book, virtual_book)
>>> cart.books.all()
<QuerySet [<Book: [Physical] Yitik Cennet>, <Book: [Virtual] Çöle İnen Nur>]>
  • Karmaşıklık azaldı ancak validasyon daha komplike hale geldi.
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator

class Book(models.Model):

    # ...

    def clean(self) -> None:

        if self.type == Book.TYPE_VIRTUAL:

            try:
                weight = int(self.extra['weight'])
            except ValueError:
                raise ValidationError(
                    'Weight must be a number'
                )
            except KeyError:
                pass
            else:
                if weight != 0:
                    raise ValidationError(
                        'A virtual product weight cannot exceed zero.'
                    )

            try:
                download_link = self.extra['download_link']
            except KeyError:
                pass
            else:
                # Will raise a validation error
                URLValidator()(download_link)

        elif self.type == Book.TYPE_PHYSICAL:

            try:
                weight = int(self.extra['weight'])
            except ValueError:
                raise ValidationError(
                    'Weight must be a number'
                 )
            except KeyError:
                pass
            else:
                if weight == 0:
                    raise ValidationError(
                        'A physical product weight must exceed zero.'
                     )

            try:
                download_link = self.extra['download_link']
            except KeyError:
                pass
            else:
                if download_link is not None:
                    raise ValidationError(
                        'A physical product cannot have a download link.'
                    )

        else:
            raise ValidationError(f'Unknown product type "{self.type}"')

  • Uygun bir sınıf alanının(field) kullanım faydası tip validasyonudur. JSONField kullanırken tip ve değer kontrolünü yapmanız gerekmektedir.
>>> book = Book.objects.create(
...     type=Book.TYPE_VIRTUAL,
...     name='Yitik Cennet',
...     price=1000,
...     extra={'weight': 100},
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A virtual product weight cannot exceed zero.']}
  • Bütün veritabanı sistemleri JSONField'ı desteklemez.
  • PostgreSQL'de aşağıdaki sorguyu yapabilirsiniz.
>>> Book.objects.filter(extra__weight__gt=10)
<QuerySet [<Book: [Physical] Yitik Cennet>]>
  • JSONField kullanarak oluşturduğunuz alanlar için not null, unique, foreign key gibi kısıtlamaları yapamazsınız.
  • Semi-structured Model yaklaşımı NoSQL veritabanı sistemlerine yakındır ve avatajları/dezavantajları vardır.

Avantajlar

  • Karmaşıklık azalır. Ortak alanlar model sınıfında, diğer alanlar JSONField'da tutulur.
  • Yeni model tip ekleme kolaylığı. Yeni tip modeller eklerken veritabanı üzerinde değişiklik gerekmez.

Dezavantajlar

  • Komplike olması ve özel validasyon mantığı gerekmesi. JSONField validasyonu için tip ve değer validasyonu da gereklidir.
  • Veritabanı kısıtlamalarını verimli kullanamamak. null, unique, foreign key kısıtlarını kullanamama problemi.
  • Belirli veritabanı sistemine bağımlı kalmak.
  • Veritabanı değişiklikleri geriye dönük bağdaşımları ve özel migration'ları gerektirebilir. Verileriniz dağılıp bozulabilir.
  • Alanlar için veritabanında meta-data saklanmaz.

Use Case

  • Semi-structured model çok fazla ortak niteliği(attributes) olmayan ve yeni niteliklerin sıkça eklendiği heterojen objeler için idealdir. Eventleri, Logları, Analitikleri depolarken kullanılır. Analitik ve log eventlerinde az efor ile yeni alanlar eklemek önemli olduğu için bu yaklaşım idealdir.

Abstract Base Model

  • İşletmenizde yeni türden ürünler satmaya başladığınızı ve çeşitliliğinizi arttırdığınızı varsayalım. Bütün ürünlerde ortak olan özellikleri ana bir sınıfta tanımlayıp ürün tipine göre de bu sınıfı miras alacak model sınıfları tanımlayalım. Ana model sınıf abstract olarak tanımlanacak.

models.py

from django.contrib.auth import get_user_model
from django.db import models


class Product(models.Model):
    class Meta:
        abstract = True

    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )

    def __str__(self) -> str:
        return self.name


class Book(Product):
    weight = models.PositiveIntegerField(
        help_text='in grams',
    )


class EBook(Product):
    download_link = models.URLField()
  • Shell ortamında işlemlere geçelim
>>> from app.models import *
>>> from django.contrib.auth import get_user_model
>>> u = get_user_model().objects.first()
>>> u
<User: adnan>
>>> book = Book.objects.create(
    name='Yitik Cennet', 
    price=20, 
    weight=100
)
>>> book
<Book: Yitik Cennet>
>>> ebook = EBook.objects.create(
...     name="Çöle İnen Nur",
...     price=50,
...     download_link='https://mybooks.com/12345'
... )
>>> ebook
<EBook: Çöle İnen Nur>

models.py

class Cart(models.Model):
    user = models.OneToOneField(
       get_user_model(),
       primary_key=True,
       on_delete=models.CASCADE,
    )
    # Attention!
    items = models.ManyToManyField(Product)
  • Eğer ManyToMany olan bir sınıf alanını(field) abstract bir sınıfa bağlamak isterseniz makemigrations komutu verirken aşağıdaki hata ile karşılaşırısnız.
SystemCheckError: System check identified some issues:

ERRORS:
app.Cart.items: (fields.E300) Field defines a relation with model 'Product', which is either not installed, or is abstract.
app.Cart.items: (fields.E307) The field app.Cart.items was declared with a lazy reference to 'app.product', but app 'app' doesn't provide model 'product'.
app.Cart_items.product: (fields.E307) The field app.Cart_items.product was declared with a lazy reference to 'app.product', but app 'app' doesn't provide model 'product'.

  • Abstract model sınıfı sadece kod üzerinde mevcuttur. Veritabanında tablo olarak karşılığı yok aslında.
  • ManyToMany ilişki kurmak istediğiniz modellerin hepsini model sınıfınıza eklemeniz gerekmektedir.
class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(Book)
    ebooks = models.ManyToManyField(EBook)
  • shell ortamında
>>> user = get_user_model().objects.first()
>>> cart = Cart.objects.create(user=user)
>>> cart.books.add(book)
>>> cart.ebooks.add(ebook)
  • Toplam fiyat bilgisini almak için
>>> from django.db.models import Sum
>>> from django.db.models.functions import Coalesce
>>> (
...     Cart.objects
...     .filter(pk=cart.pk)
...     .aggregate(total_price=Sum(
...         Coalesce('books__price', 'ebooks__price')
...     ))
... )
{'total_price': 1000}

Avantajlar

  • Ayrı model sınıflarını implemente etmek kolay, spesifik mantıksal işler test edilebilir, sürdürülebilirdir.

Dezvantajlar

  • Çok fazla foreign key ihtiyacı var. Ana model sınıftan türeyen her alt sınıf için bir foreign key gerekiyor.
  • Bütün alt model sınıflarının implementasyonu ve test edilmesi zor.
  • Scale etmek çok zor.

Use Case

  • Çok az biçimde obje modelleri ve gayet açık bir işlem varsa ideal bir yaklaşımdır. Örneğin ödeme tipleri için kullanılabilir. Ödeme tipleri kredi kartı, PayPal, banka kartı gibi sınırlı olduğu için bu yaklaşım uygun olabilir.

Concrete Base Model

  • Abstract base model yerine Django, 'Concrete' model kullanmaya olanak sağlar. Concrete model veritabanında bulunur ve diğer model sınıflar bu model ile One-to-One ilişki kurarlar.

models.py

from django.contrib.auth import get_user_model
from django.db import models


class Product(models.Model):
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )

    def __str__(self) -> str:
        return self.name


class Book(Product):
    weight = models.PositiveIntegerField()


class EBook(Product):
    download_link = models.URLField()
  • shell ortamı
>>> from app.models import *
>>> book = Book.objects.create(
...     name='Yitik Cennet',
...     price=20,
...     weight=100,
... )
>>> book
<Book: Yitik Cennet>
>>> ebook = EBook.objects.create(
...     name='Çöle İnen Nur',
...     price='50',
...     download_link='https://mybooks.com/123456',
... )
>>> ebook
<EBook: Çöle İnen Nur>
>>> 
  • Book.objects.filter(pk=1).query sorgusu ile 1 adet kitap için yapılan veritabanı sorgusunu görelim:
SELECT
    "concrete_base_model_product"."id",
    "concrete_base_model_product"."name",
    "concrete_base_model_product"."price",
    "concrete_base_model_book"."product_ptr_id",
    "concrete_base_model_book"."weight"
FROM
    "concrete_base_model_book"
    INNER JOIN "concrete_base_model_product" ON
        "concrete_base_model_book"."product_ptr_id" = "concrete_base_model_product"."id"
WHERE
    "concrete_base_model_book"."product_ptr_id" = 1
  • Cart modeline ManyToMany alanı ekleyebiliriz.
class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    items = models.ManyToManyField(Product)
  • shell ortamı
>>> from app.models import *
>>> from django.contrib.auth import get_user_model
>>> user = get_user_model().objects.first()
>>> user
<User: adnan>
>>> cart = Cart.objects.create(user=user)
>>> book = Book.objects.first()
>>> ebook = EBook.objects.first()
>>> cart.items.add(book, ebook)
>>> cart.items.all()
<QuerySet [<Product: Yitik Cennet>, <Product: Çöle İnen Nur>]>
>>> 
  • Ortak alanlar(fields) ile çalışmak oldukça kolaydır.
>>> from django.db.models import Sum
>>> cart.items.aggregate(total_price=Sum('price'))
{'total_price': 70}

Avantajlar

  • Ortak nitelikler bir tane tablodan sorgulanabilir. price, name gibi.
  • primary key bütün tiplerde tutarlıdır.

Dezavantajlar

  • Yeni Product tipi yeni bir model sınıfına ihtiyaç duyarve veritabanında değişiklik gereklidir.
  • Verimsiz veritabanı sorguları üretebilir.

Use Case

  • Bu yaklaşım ana model sınıfındaki alanlar çok sık yapılan sorgular için yeterli ise tercih edilir. Örneğin; alışveriş sepetindeki toplam fiyatı, ürünlerin listesini göstermek için, özel bir amaca yönelik sepetteki analitik sorguları çalıştırmak için, bir tablodaki ortak niteliklerin hepsinden yararlanabilirsiniz.

Kaynak

Yorumlar