Laravel Eloquent - Performance Patterns

Makale İçerik Listesi

Laravel Debug Barı Yükle

  1. Laravel Debug Bar database query performanslarımızı görmek açısından çok önemli bir araç.
  2. https://github.com/barryvdh/laravel-debugbar
  3. Debug bar'da Laravel'de yaptığımız Query'lerin SQL çıktısını görebiliriz. Oradaki SQL'i alıp TablePlus'a kopyalasak ve başına EXPLAIN desek, hangi keyler index olarak kullanılmış (possible_keys, key) olarak görebiliriz.

GroupBy derken Strict Mode sorunu

Bazen SQL sorgusunda ->groupBy('') metodunu çalıştırmak istediğimizde Strict mode hatası alabiliriz. Bu durumda yapmız gereken config/database.php dosyasında strict modu'u kapatmak olur. config/database.php (l. 54) 

'strict' => false,

latest() oldest()

  1. En eski veya en yeniye göre sıralamak istediğinde bunları kullanabilirsin. 
    Post::query()
       ->with('author')
       ->latest('published_at')
       ->get()
       ->groupBy(fn ($post) => $post->published_at->year);
    
    return View::make('posts', ['years' => $years]);

Zaman satırı ile çalışmak

Zaman nesnesi zaten belli olduğu için onda Carbon::parse demeden de direk zaman metodlarını çalıştırabiliriz. 

{{ $post->published_at->toFormattedDateString() }}

Modele Bağlı bir Relationship'ten kaç tane var?

  1. X bir model veritabanından çekilirken o modele bağlı bir ilişkinin kaç verisi olduğu da önemli bir sorun olabilir. Bunun için withCount() metodunu kullanırız. 
    $feature = Feature::query()->withCount('comments')->paginate();
  2. Bir modele bağlı başka bir model'den kaç tane var... Onların ilk 10'unu bana getir 
    $most_stikes = Trial::where('premium', true)
                ->withCount('strikes')
                ->orderBy('strikes_count','DESC')
                ->limit(10)
                ->get();

Diğer Aggregate Fonksiyonlar

  1. withCount() yanı sıra withMin, withMax, withAvg, withSum, ve withExists gibi aggregate fonksiyonlar da var.
  2. $posts = Post::withSum('comments', 'votes')->get();
  3. $posts = Post::withSum('comments as total_comments', 'votes')->get();

Belli Koşullara Bağlı Veri Toplamları Almak

  1. Özellikle index sayfalarında genel toplamların olduğu alanlar görmek isteriz. Şu'ndan kaç kişi var? Şu'ndan kaç tane var? gibi. Bunları veritabanından tek seferde çekmek için MySQL'de şöyle bir sorgu yapmamız gerekir. Ör. 
    select
       count(case when status="Requested" then 1 end) as requested,
       count(case when status="Planned" then 1 end) as requested,
       count(case when status="Completed" then 1 end) as requested,
    from features
  2. Bunu Laravel'de Eloquent ile nasıl yazarım? 
    $status = Feature::toBase()
       ->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
       ->selectRaw("count(case when status = 'Planned' then 1 end) as requested")
       ->selectRaw("count(case when status = 'Completed' then 1 end) as requested")
       ->first();

When ile Dinamik koşullu Sorgular

  1. Dinamik koşulların olduğu sorgularda when() kullanılabilir. Laravel Docs Conditional Clauses
    $orderby = $request->has('orderby') ? $request->string('orderby') : 'none';
    $filter = $request->has('filter') ? $request->string('filter') : 'none';
    
    $companies = Company::query()
                ->when($orderby == 'az', function($companies) {
                    $companies->orderBy('alias');
                })
                ->when($orderby == 'za', function($companies) {
                    $companies->orderBy('alias','desc');
                })
                ->when($filter == 'premiums', function($companies) {
                    $companies->where('type_id', 1);
                })
                ->when($filter == 'trials', function($companies) {
                    $companies->where('type_id', 4);
                })
                ->paginate(100);

    daha farklı bir kaynak daha

Eager Loading Yapmazsak Blade içinde de yapabiliriz

{{ $user->logins()->latest()->first()->created_at->diffForHumans() }}

Eager Loading'i URL'de aldığın modele eklemek

  1. Ör. ArticleController'daki show method'una Implicit Route binding yaptık ve show(Article $article) metoduna $article geliyor. Ama ben onu almanın ötesinde bir de eager loading eklemek istiyorum. O zaman load() metodunu kullanabilirim. 
    public function show(Article $article)
    {
       $article->load('comments.user');
    
       return view('article-show', ['article' => $article]);
    }

Eloquent içinde Join Kullanımı

$query->join('companies', 'companies.id', '=', 'users.company_id');

SetRelation ile Manuel Relation Eklemek

  1. Eloquent'ın setRelation('relation_key', Relation_Object) metodu ile Modelde yaptığımız ilişkilenmeyi manuel olarak ta yapabiliriz. 
    public function show(Article $article)
    {
       $article->load('comments.user');
       $article->comments->each->setRelation('article', $article);
    
       return view('article-show', ['article' => $article]);
    }
  2. Ne yaptık? $article verisinin içindeki $comment'in içine yine $article verisini atayabildik.
  3. Bunun ne zaman faydası olur ki? Circular relationship olduğu zaman faydalı bir yöntem.

Daha Performanslı Query'ler için Query sırasında select

Post::query()
   ->select('id', 'title', 'slug', 'published_at', 'author_id')
   ->with(['author' => function($query) { 
            $query->select('id', 'name')
   }])
   ->latest('published_at')
   ->get()
   ->groupBy(fn ($post) => $post->published_at->year);

return View::make('posts', ['years' => $years]);

hatta with tarafından daha da temiz yazmak istersen ->with('author:id,name') de diyebilirsin. 

Post::query()
   ->select('id', 'title', 'slug', 'published_at', 'author_id')
   ->with('author:id,name')
   ->latest('published_at')
   ->get()
   ->groupBy(fn ($post) => $post->published_at->year);

return View::make('posts', ['years' => $years]);

Modelin içinde olmayan bir değeri Subquery ile Çağırmak

  1. User modelinde olmayan bir değeri addSelect() ile sanki o tabloda varmış gibi subquery üzerinden ekleyebilirsin. 
    $users = User::query()
        ->addSelect(['last_login_at' => Login::select('created_at')
               ->where('user_id', 'users.id')
               ->latest()
               ->take(1)
       ])
       ->orderBy('name')
       ->paginate();

    Burada yaptığımız şey users tablosuna last_login_at adında sanal bir sütun daha eklemek oluyor. Ama bu metodun çalışması için addSelect'in sadece bir değer dönmesi lazım. O yüzden subquery sonunda ->take(1) demek zorundayım.

OrderBy Subquery

Laravel'de veritabanından bir sorgu dönerken ör. subquery için sıralamayı belli bir özelliğe göre getirmesini istersen subquery yapman gerekir. Şu şekilde: 

$product_variation_sets = VariationSet::with(['items' => function($query) {
                $query->orderBy('order');
            }])
            ->where('product_id', $product->id)
            ->get();

Advance Join

Bir tablodaki belli bir set ile başka bir tabloyu anahtar üzerinden birbirine bağlayarak daha büyük bir set alırken burada bir alt koşul talep ediyorum ve bütün bu koşulları olumlu olan setin boyutunu öğrenmek istiyorum 

return DB::table('goals')
            ->where('field_id', $this->id)
            ->join('tasks', function ($join) {
                $join->on('goals.id', '=', 'tasks.goal_id')->where('tasks.is_done', '=', false);
            })
            ->count();

Bu konuda dokumantasyon - Laravel Advanced Join Clauses

Scope'lar ile Query'leri İzole etmek

Örneğin UsersController'daki metodlardan birinde kullanıcıları son login tarihine göre çektiğimiz bir sorgumuz olsa 

$users = User::query()
    ->addSelect(['last_login_at' => Login::select('created_at')
           ->where('user_id', 'users.id')
           ->latest()
           ->take(1)
   ])
   ->withCasts(['last_login_at' => 'datetime'])
   ->orderBy('name')
   ->paginate();

Bu sorguda bazı problemler var...

  1. Çok fazla spesifik mantık controller içine girmiş oldu.
  2. user'a last_login_at sorgusunun eklendiği alanı bir çerçeve içine alsak, bunu users için veya tek bir kullanıcı için vs. de  kullanabiliriz aslında ama bu durumda kullanamıyoruz.
  3. Çözüm => User.php Model'in içine scope olarak bu logic'i atabiliriz. User.php 
    public function scopeWithLastLogin($query)
    {
       $query->addSelect(['last_login_at' => Login::select('created_at')
           ->where('user_id', 'users.id')
           ->latest()
           ->take(1)
        ])
        ->withCasts(['last_login_at' => 'datetime']);
    }

    User Model'inde bu scope oluştuktan sonra artık bunu User içinde query() with() where() gibi kullanabilirim. 

    $users = User::query()
       ->withLastLoginAt()
       ->orderBy('name')
       ->paginate();

Subquery ile Dinamik Bir İlişki Oluşturmak

  1. Üstteki gibi last_login_at... hatta last_login_ip vs. diye subquery ile tabloda olmayan verileri tabloya aktarabiliriz.
  2. Ama bu iş büyüdükçe, her bir istek için yeni bir subquery oluşturmak işi çok uzatabilir.
  3. Ayrıca bu şekilde yaptığımızda subquery ile birleştirilen veri'nin modelinden habersiz olunacağı için hiçbir helper metod vs kullanılamaz hale geliyor.
  4. Bu işi çözebilir miyiz? EVET! :)
  5. Nasıl?
  6. User Model'in içine bir ilişki daha ekliyorum. 
    public function lastLogin()
    {
       return $this->belongsTo(Login::class);
    }

    ama bu ilişki aslında One-to-many ve karşılığında last_login_id olmasını bekler. Bu değeri öncelikle dinamik değer olarak hesaplatırsak Laravel bu değerin dinamik getirildiğini algılamadan ilişkiyi doğru biçimde bağlar. 

    public function lastLogin($query)
    {
       return $this->belongsTo(Login::class);
    }
    
    public function scopeWithLastLogin($query)
    {
       $query->addSelect(['last_login_at' => Login::select('created_at')
           ->where('user_id', 'users.id')
           ->latest()
           ->take(1)
        ])->with('lastLogin');
    }
  7. UsersController'da artık bu şekilde scope üzerinden çalıştırdığımızda last_login_at sadece bir veri satırı olarak değil, Login Model'i ile birlikte gelir. 
    public function index()
    {
       $users = User::query()
           ->withLastLogin()
           ->orderBy('name')
           ->paginate();
       
        return View::make('users', ['users' => $users]);
    }
  8. Böylece artık blade içerisinde bu Model'in diğer değerlerini ve helper metodlarını vs. kullanabilirim. 
    {{ $user->lastLogin->created_at->diffForHumans() }}
    {{ $user->lastLogin->ip_address }}

Subquery ile Dinamik İlişki - Has One of Many

  1. Yukarda bahsettiğimiz kurgu Laravel 8.x ile Laravel Core'un çok daha kullanışlı bir parçası haline geldi.
  2. https://laravel.com/docs/8.x/eloquent-relationships#has-one-of-many
  3. En Yenisi için latestOfMany() 

    /**
     * Get the user's most recent order.
     */
    public function latestOrder()
    {
        return $this->hasOne(Order::class)->latestOfMany();
    }
  4. En Eskisi için oldestOfMany() 
    /**
     * Get the user's oldest order.
     */
    public function oldestOrder()
    {
        return $this->hasOne(Order::class)->oldestOfMany();
    }
  5. Sıralanabilir başka bir satır üzerinden hasOne of Many diyeceksek o zaman ona 2. arguman olarak (min/max) verebiliriz 
    /**
     * Get the user's largest order.
     */
    public function largestOrder()
    {
        return $this->hasOne(Order::class)->ofMany('price', 'max');
    }
  6. Daha da advanced hasOneOfMany için https://laravel.com/docs/8.x/eloquent-relationships#advanced-has-one-of-many-relationships 
    /**
     * Get the current pricing for the product.
     */
    public function currentPricing()
    {
        return $this->hasOne(Price::class)->ofMany([
            'published_at' => 'max',
            'id' => 'max',
        ], function ($query) {
            $query->where('published_at', '<', now());
        });
    }

Yüksek Performanslı - Arama Komponenti 1. Seviye

  1. Bir arama kutumuz var. İçine birşey yazıp arattığımızda bize sonuç döndürecek. O zaman bunu request('search') diye alalım ve bir search scope'u oluşturalım. UserController'da durumumuz şu şekilde olur 
    public function index()
    {
       $users = User::query()
          ->search(request('search'))
          ->with('company')
          ->paginate();
    
       return view('users', ['users' => $users]);
    }
  2. Öte yandan User Model'inde belirlediğimiz search scope ise en basit ve performanssız hali ile şu şekilde olacaktır 
    public function scopeSearch($query, string $term = null)
    {
       collect(explode(' ', $term))->filter()->each(function ($term) use ($query) {
          $term = '%' . $term . '%';
    
          $query->where(function($query) use ($term) {
             $query->where('first_name', 'like', $term)
                ->orWhere('last_name', 'like', $term)
                ->orWhereHas('company', function($query) use ($term) {
                   $query->where('name', 'like', $term);
                });
          });
       });
    }
    1. Burada öncelikle arama terimini üzerinde operasyon yapabilmek için bir collection içine aldık.
    2. Daha sonra bu terimi explode() ile parçalarına böldük.
    3. Elde ettiğimiz collection üzerine filter() ı boş olarak kullandık ve trimming işlemi yaptık.
    4. Her bir terimi SQL Like operatöründe rahat arama yapması için '%' içine aldık.
    5. Aranan kelimeyi 3 farklı yerde arayacağız... ama bunların hepsi tek bir koşul içinde olsun istediğimiz için üst bir where fonksiyonunun içinde arattık.
      ARA NOT: Eğer örneğin sorguyu şu şekilde yapsak 
      $user->posts()
              ->where('active', 1)
              ->orWhere('votes', '>=', 100)
              ->get();

      bunun SQL tarafındaki karşılığı 

      select *
      from posts
      where user_id = ? and active = 1 or votes >= 100

      yani or'dan sonraki kısımda user_id artık önemsiz hale gelmiştir.
      Yani burada X bir kullanıcıya sınırlanan mantık bağı kopmuştur. Sorgu 100+ oy alan bütün postları kullanıcı id'sine bakmaksızın döndürecektir.
      Halbuki biz x bir kullanıcının makaleleri arasından aktif olan veya oyu 100+ olanları istiyoruz. Bunun için sorguyu bir  mantık grubu olarak belirtmek için parantez içine almamız gerekir. 

      $user->posts()
              ->where(function (Builder $query) {
                  return $query->where('active', 1)
                               ->orWhere('votes', '>=', 100);
              })
              ->get();

      Böylece SQL'de yaptığımız sorguyu da 

      select *
      from posts
      where user_id = ? and (active = 1 or votes >= 100)

      dönüştürmüş olduk.

       

      Dokumantasyondaki açıklaması için link

    6. Peki orWhereHas() nedir?
    7. Bazen veritabanından döndürdüğümüz sonuçlarda belli bir ilişkinin karşılığı varsa olan sonuçları döndürmesini isteyebiliriz. Örneğin Post->hasMany(Comment::class) diye bir olası ilişki belirlemiş olabiliriz. Ama biz en azından bir comment'i olan Post'ları döndürmek istersek 
      // Retrieve all posts that have at least one comment...
      $posts = Post::has('comments')->get();

      dememiz gerekir. Tabii bunun tam tersi de yapılabilir 

      $posts = Post::doesntHave('comments')->get();


      Veya sorguya koşul da ekleyebiliriz 

      // Retrieve all posts that have three or more comments...
      $posts = Post::has('comments', '>=', 3)->get();

      Hatta nokta ile iç ilişkiler üzerinden de sorguyu daha net hale getirebiliriz. 

      // Retrieve posts that have at least one comment with images...
      $posts = Post::has('comments.images')->get();

      Bütün bunların daha da üstünde bir de iç sorgu yapmak istersek o zaman whereHas veya orWhereHas kullanabiliriz.

      // Retrieve posts with at least one comment containing words like code%...
      $posts = Post::whereHas('comments', function (Builder $query) {
          $query->where('content', 'like', 'code%');
      })->get();
      
      // Retrieve posts with at least ten comments containing words like code%...
      $posts = Post::whereHas('comments', function (Builder $query) {
          $query->where('content', 'like', 'code%');
      }, '>=', 10)->get();

      Tabii bunun tam tersi de mevcut 

      $posts = Post::whereDoesntHave('comments', function (Builder $query) {
          $query->where('content', 'like', 'code%');
      })->get();

      Dokümantasyon'dan - https://laravel.com/docs/8.x/eloquent-relationships#querying-relationship-existence

  3. Şimdi bu çalışan bir yöntem. Sıra bunu refactor etmeye geldi!

Yüksek Performanslı - Arama Komponenti 2. Seviye

  1. Öncelikle daha performanslı sorgu yapmak için arama yaptığımız text alanlarını index'lememiz gerekir. Bunun için bir migration yapalım  
    Schema::create('users', function(Blueprint $table) {
        $table->id();
        $table->foreignId('company_id')->constrained('companies');
        $table->string('first_name')->index();
        $table->string('last_name')->index();
        $table->timestamp();
    });
    
    Schema::create('companies', function(Blueprint $table) {
        $table->id();
        $table->string('name')->index();
        $table->timestamp();
    })
  2. Index migration yaptıktan sonra üstteki 1. seviye Arama örneğinde yaptığımız sorguyu EXPLAIN ile TablePlus'ta incelesek, indexlerin hiçbirinin kullanılmadığını görürüz.
  3. Index'in kullanılması için '%' . $term . '%' yerine başında '%' olmadan sorgumuzu çalıştırmamız gerekir. 
    SELECT * FROM `users`
    WHERE (`first_name` LIKE 'bill%' OR `last_name` LIKE 'bill%')
    and (`first_name` LIKE 'gates%' OR `last_name` LIKE 'gates%')
    and (`first_name` LIKE 'microsoft%' OR `last_name` LIKE 'microsoft%')
    LIMIT 15 OFFSET 0
  4. public function scopeSearch($query, string $term = null)
    {
       collect(str_getcsv($term, ' ', '"'))->filter()->each(function ($term) use ($query) {
          $term = $term . '%';
    
          $query->where(function($query) use ($term) {
             $query->where('first_name', 'like', $term)
                ->orWhere('last_name', 'like', $term)
                ->orWhereIn('company_id', function($query) use ($term) {
                   $query->select('id')->from('companies')->where('name', 'like', $term);
                });
          });
       });
    }

Yüksek Performanslı - Arama Komponenti 3. Seviye

  1. public function scopeSearch($query, string $term = null)
    {
       collect(str_getcsv($term, ' ', '"'))->filter()->each(function ($term) use ($query) {
          $term = $term . '%';
    
          $query->where(function($query) use ($term) {
             $query->where('first_name', 'like', $term)
                ->orWhere('last_name', 'like', $term)
                ->orWhereIn('company_id', Company::query()->where('name', 'like', $term)->pluck('id'));
          });
       });
    }

    Burada hem ana query var hem de her bir terim için ayrıca işleyen bir Company::query() var ama Company::query() zaten inanılmaz optimize olduğu için sonuç yine de çok iyi.

  2. Buna rağmen hepsini tek bir query içinde yapmanın da bir yolu var!

Yüksek Performanslı - Arama Komponenti 4. Seviye