TR
Başlangıç

Arama

DataTable ve liste sayfalarının arama kutusu Türkçe harflere ve büyük/küçük harfe takılmadan arar; büyük tablolarda da hızlı kalır. Bu sayfa nasıl çalıştığını ve nasıl ayarlanacağını anlatır.

Size sağladıkları:

  • Kullanıcı "ayse" yazınca "Ayşe", "ismail isik" yazınca "İsmail Işık" bulunur.
  • Varsayılan arama indeks kullanır; milyonlarca satırda da tabloyu baştan sona okumaz.
  • DataTable ve WithListing aynı altyapıyı kullanır: aynı alan tanımı (SearchField), aynı sürücü, aynı terim için aynı satırlar.
  • Yazım hatası toleransı gerekirse kendi sürücünüzle bir arama motoruna (Meilisearch, Typesense) geçersiniz; tablo kodu değişmez.

Not

Arama altyapısı PRO paketindedir (acunsoft/acun-ui-pro); Acun temalarında kurulu gelir.

En kısa örnek

Bir migration'da arama sütununu ekleyin:

Schema::table('customers', function (Blueprint $table) {
    $table->searchColumn('email');   // email_search sütunu ve indeksi
});

Sonra alanı aranabilir yapın. DataTable'da sütunu işaretleyin, liste sayfasında alanı verin:

// DataTable
Column::make('E-posta', 'email')->searchable(),

// WithListing
protected function searchFields(): array
{
    return [SearchField::make('email')];
}

Arama, veritabanının email sütunundan ürettiği email_search sütununda çalışır. Bu bir üretilen sütundur (generated column): değerini veritabanı hesaplar ve günceller. Varsayılan arama baştan aramadır ve indeksi kullanır.

Neden üretilen bir arama sütunu?

Arama, verinin aranmaya hazır bir kopyasında yapılır. Kaynak sütun olduğu gibi kalır:

name            name_search
Ayşe Yılmaz     ayse yilmaz
İsmail IŞIK     ismail isik

Her katman tek bir iş yapar:

  • Kaynak sütun saklar: Veriyi olduğu gibi UTF-8 olarak tutar ("Ayşe Yılmaz"). Görüntüleme ve sıralama onu kullanır.
  • Arama sütunu sadeleştirir: Türkçe harfleri ve büyük/küçük harfi tek biçime indirir ("ayse yilmaz").
  • İkili karşılaştırma kuralı (binary collation) karşılaştırır: Değer zaten sadeleştiği için bayt bayt karşılaştırılır; dil kuralı gerekmez.
  • İndeks hızlandırır: Baştan arama indekste gezinir; tabloyu baştan sona okumaz.

Sütunu veritabanı ürettiği için insert, ham update, içe aktarma ve Model::query()->update() da onu kendiliğinden günceller. PHP'de doldurma, model olayı ya da geriye dönük doldurma yoktur. Sorgu anında LOWER(REPLACE(sütun…)) LIKE '%…%' yazmak ise her aramada bütün satırları okur ve indeks kullanamaz.

Sadeleştirme kuralı PHP'de Acun\Ui\Search\Normalizer::fold(), SQL'de Normalizer::expression() ile uygulanır. İkisi aynı sonucu verir:

  1. İ, I, ı → i · Ş, ş → s · Ç, ç → c · Ğ, ğ → g · Ö, ö → o · Ü, ü → u. Bu adım küçük harfe çevirmeden önce yapılır.
  2. Küçük harfe çevrilir. É, Ä gibi diğer harfleri MySQL, MariaDB, PostgreSQL ve SQL Server küçültür; SQLite yalnızca A–Z'yi küçültür.
  3. Sekme ve satır sonları boşluk olur, art arda boşluklar teke iner, baştaki ve sondaki boşluk silinir. SQL tarafı 32 boşluğa kadar olan dizileri teke indirir.

Böylece "ayse" → "Ayşe", "ali" → "ALİ" ve "ALI", "ismail isik" → "İsmail Işık", "ÇAĞRI" → "çağrı" bulunur. Türkçe arama ayrı bir mod değildir; her mod bu sadeleşmiş değerlerde çalışır.

Yazım hataları

Sadeleştirme yalnızca harfleri ve büyük/küçük harfi tek biçime indirir: "aysse", "Ayşe"yi bulmaz. Veritabanı sürücüsünde bulanık arama, benzerlik ya da ses benzerliği araması yoktur. Yazım hatası toleransı ve alaka sıralaması gerekiyorsa bir arama motoru için kendi sürücünüzü yazın (bkz. Sürücüler); tablo kodu değişmez.

Arama sütunu eklemek

searchColumn(), bir migration'da arama sütununu ve indeksini ekler:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->searchColumn('number');                   // number_search + indeks
            $table->searchColumn('customer_email');           // customer_email_search + indeks
            $table->searchColumn('customer_name', index: false);

            // Birden çok kaynak: tek boşlukla birleşir, NULL boş sayılır; ad zorunludur.
            $table->searchColumn(['customer_name', 'customer_email'], 'customer_search');
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropSearchColumn('number_search');
            $table->dropSearchColumn('customer_email_search');
            $table->dropSearchColumn('customer_name_search', index: false);
            $table->dropSearchColumn('customer_search');
        });
    }
};
Parametre Varsayılan Ne yapar
$source — Kaynak sütun ya da sütun listesi
$name {kaynak}_search Sütun adı; birden çok kaynakta zorunlu
index true Baştan ve tam arama için indeks; yalnızca içinde aranan sütunda gereksiz
fullText false Tam metin indeksi ekler; veritabanına göre aşağıya bakın
length 255 varchar uzunluğu; 0 sınırsız text demektir

_search eki acun-ui.search.suffix, varsayılan uzunluk acun-ui.search.length ile değişir. Uzunluğu aşan değerin baştaki karakterleri tutulur.

dropSearchColumn() sütunu indeksiyle birlikte kaldırır. Sütunu oluştururken verdiğiniz index ve fullText değerlerini ona da verin.

Sütun adları sizin kuralınıza uyabilir. Örneğin Türkçe adlandırılan bir projede $table->searchColumn('name', 'ad_arama').

Veritabanına göre ne oluşur

  • SQLite: VIRTUAL sütun, varsayılan BINARY karşılaştırma kuralı ve normal indeks. fullText etkisizdir; FullText modu içinde aramaya döner.
  • MySQL / MariaDB: VIRTUAL sütun; fullText: true ile STORED. Karşılaştırma kuralı utf8mb4_bin. Normal indeks; length: 0 ise ilk 191 karakter indekslenir. fullText: true bir FULLTEXT indeks ekler; bu indeks STORED sütun ister.
  • PostgreSQL: STORED sütun, "C" karşılaştırma kuralı ve normal btree indeks. fullText: true bir pg_trgm GIN indeksi (gin_trgm_ops) ekler. pg_trgm uzantısı önceki bir migration'da kurulmuş olmalıdır; yoksa migration açıklayıcı bir hata verir.
  • SQL Server: PERSISTED hesaplanan sütun (computed column), Latin1_General_100_BIN2 karşılaştırma kuralı ve normal indeks. fullText etkisizdir.

MySQL'de üretilen SQL komutu (kısaltılmış):

alter table `orders` add `customer_email_search` varchar(255) collate 'utf8mb4_bin'
    as (SUBSTR(TRIM(REPLACE(… LOWER(REPLACE(REPLACE(`customer_email`, 'İ', 'i'), 'I', 'i') …) …)), 1, 255));
alter table `orders` add index `orders_customer_email_search_index`(`customer_email_search`);

PostgreSQL'de:

alter table "orders" add column "customer_email_search" varchar(255) collate "C" null
    generated always as (SUBSTR(TRIM(REPLACE(… LOWER(REPLACE("customer_email", 'İ', 'i') …) …)), 1, 255)) stored;
create index "orders_customer_email_search_index" on "orders" ("customer_email_search");

İfade yalnızca her seferinde aynı sonucu veren işlevler kullanır. PostgreSQL'de bunlar IMMUTABLE işlevlerdir: replace, lower, trim, substr, coalesce, ||. concat_ws STABLE olduğu için kullanılmaz.

Büyük mevcut tablolar

Mevcut büyük bir tabloya sütun eklemenin maliyeti veritabanına göre değişir:

  • MySQL / MariaDB: VIRTUAL sütun eklemek anlık bir ALTER'dır; indeks çevrimiçi kurulur. fullText: true ise STORED sütun ve FULLTEXT indeks tabloyu yeniden yazar. Milyonlarca satırda bunu yoğun olmayan bir saate ya da pt-online-schema-change, gh-ost gibi bir çevrimiçi şema aracına planlayın.
  • PostgreSQL: STORED sütun eklemek tabloyu yeniden yazar ve bu sırada tabloyu kilitler. İndeksi ayrı bir migration'da CREATE INDEX CONCURRENTLY ile kurmak için index: false verip komutu kendiniz yazabilirsiniz.
  • SQLite: VIRTUAL sütun anlıktır.

Dikkat edilecekler

  • SQLite'ta tabloyu yeniden kuran değişiklikler. ->change() ya da yabancı anahtar ekleyip silmek, Laravel'in SQLite'ta tabloyu yeniden kurmasına yol açar. Bu sırada üretilen sütun düz bir sütuna dönüşür ve kaynağını izlemeyi bırakır. Yerel ve test ortamında paket bunu hata ile bildirir. Çözüm: yeni bir migration'da iki ayrı Schema::table() çağrısıyla önce dropSearchColumn(), sonra searchColumn(). dropColumn() ve renameColumn() güvenlidir.
  • PostgreSQL'de kaynak sütunun tipi. Üretilen bir sütunun kullandığı sütunun tipi değiştirilemez. Önce dropSearchColumn(), sonra tip değişikliği, en son yeniden searchColumn().
  • select *. Arama sütunları modele de gelir. Model JSON'a çevriliyorsa sütunları $hidden listesine ekleyin. replicate() kullanıyorsanız bu sütunları hariç tutun; üretilen sütuna yazılamaz.

Arama modları

Mod, terimin değerin neresinde aranacağını belirler:

use Acun\Ui\Search\SearchField;
use Acun\Ui\Search\SearchMode;

SearchField::make('number');                                  // Prefix: değerin başı (varsayılan)
SearchField::make('national_id', SearchMode::Exact);          // değerin tamamı
SearchField::make('customer_name', SearchMode::Contains);     // değerin herhangi bir yeri
SearchField::make('customer_name', 'fulltext');               // her sözcük, sıra önemsiz
Mod Nasıl eşleşir Ne zaman
Prefix (varsayılan) Değer terimle başlar; indeks kullanır Kod, sipariş no, e-posta: baştan yazılan değerler
Exact Değer terime eşittir; indeks kullanır T.C. kimlik no, tam kod
Contains Terim değerin herhangi bir yerindedir Küçük tablolar; adın içindeki bir sözcük
FullText Terimdeki her sözcük değerde geçer; sıra önemsiz Büyük tablolarda ad gibi alanlar

Modu adıyla da yazabilirsiniz: 'prefix', 'exact', 'contains', 'fulltext'. Her mod sadeleşmiş değerlerde çalışır.

Her modun ürettiği koşul:

  • Prefix: LIKE 'terim%'; SQLite'ta GLOB 'terim*'. İndeks kullanır.
  • Exact: = 'terim'. İndeks kullanır.
  • Contains: LIKE '%terim%'. İndeks kullanmaz; PostgreSQL'de pg_trgm indeksiyle kullanır.
  • FullText: MySQL ve MariaDB'de MATCH … AGAINST('+ayse* +yil*' IN BOOLEAN MODE). Burada her sözcük değerdeki bir sözcüğün başı olmalıdır. Diğer veritabanlarında her sözcük için LIKE '%…%' kullanılır.

Bilmeniz gerekenler:

  • Prefix, "ayse yilmaz" değerini "ayse" ile bulur, "yilmaz" ile bulmaz. Ad alanlarında ikinci sözcük de aranmalıysa küçük tablolarda Contains, büyük tablolarda FullText kullanın.
  • FullText MySQL ve MariaDB'de fullText: true ile oluşan FULLTEXT indeksi kullanır. Sonuç, sunucunun tam metin ayarlarına bağlıdır: en kısa sözcük uzunluğu, durdurma sözcükleri. E-postadaki @ ve . sözcük ayırıcıdır. Terimdeki +, -, *, " gibi işleç karakterleri de sözcük ayırıcı sayılır.
  • FullText PostgreSQL'de pg_trgm GIN indeksiyle hızlanır. SQLite'ta aynı koşul indekssiz çalışır; küçük veride yeterlidir.
  • Alanlar tek bir gruplanmış where() içinde OR ile bağlanır. Sorgunun kendi koşulları (kapsamlar, yetki, filtreler) korunur. Gruptaki tek bir indekssiz alan (Contains) bütün aramayı taramaya çevirir; büyük tablolarda EXPLAIN ile kontrol edin.
  • Terimdeki % ve _ düz metindir; SQLite'ta *, ? ve [ de öyle. LIKE koşulları açık bir kaçış karakteri (ESCAPE '!') taşır; davranış her veritabanında aynıdır.
  • Boş ya da yalnızca boşluktan oluşan terim sorguyu değiştirmez.
  • Terimin en çok ilk 200 karakteri ve ilk 10 sözcüğü kullanılır (max_length, max_words).

İndeks notları

Baştan aramanın her veritabanında indeksi nasıl kullandığı:

  • SQLite, GLOB: SQLite'ın LIKE işleci ASCII'de büyük/küçük harfe duyarsızdır. İndeksi yalnızca NOCASE sütunlarda ya da bağlantı genelindeki case_sensitive_like ayarıyla kullanır. GLOB büyük/küçük harfe duyarlıdır ve olağan BINARY indeksi kullanır. Değer zaten sadeleştiği için doğru seçim budur: SEARCH orders USING INDEX orders_number_search_index (number_search>? AND number_search<?).

  • MySQL: utf8mb4_bin sütunda LIKE 'terim%' indekste aralık taraması yapar (type: range, key: …_search_index).

  • PostgreSQL: "C" karşılaştırma kurallı sütunda düz bir btree indeksi LIKE 'terim%' için kullanılır (Index Scan using …_search_index); text_pattern_ops gerekmez. Contains ve FullText için pg_trgm uzantısı ve GIN indeksi gerekir:

    DB::statement('CREATE EXTENSION IF NOT EXISTS pg_trgm');   // önceki bir migration'da
    
    Schema::table('customers', fn (Blueprint $table) => $table->searchColumn('name', fullText: true));
    // create index "customers_name_search_trigram" on "customers" using gin ("name_search" gin_trgm_ops)
    

    Küçük tablolarda sorgu planlayıcısı taramayı daha ucuz bulabilir. Üç harften kısa sözcükler üç harflik parça (trigram) üretmez.

DataTable'da arama

Arama kutusu, ->searchable() ile işaretlenen sütunlarda arar. Arama {alan}_search sütununu okur; column: ile paylaşılan bir sütuna yönlendirebilirsiniz:

use Acun\Ui\DataTable\Column;
use Acun\Ui\Search\SearchField;
use Acun\Ui\Search\SearchMode;

protected function columns(): array
{
    return [
        Column::make('Sipariş no', 'number')->searchable()->sortable(),               // number_search, baştan
        Column::make('Müşteri', 'customer_name')->searchable(SearchMode::Contains),   // customer_name_search
        Column::make('Kişi', 'customer_name')->searchable('contains', column: 'customer_search'),
    ];
}

Sütun olarak gösterilmeyen bir alanda da aramak için kendi searchFields() metodunuzu yazın. Gizli sütun eklemek gerekmez:

// E-posta, müşteri hücresinin içinde gösteriliyor; arama yine de bulur.
protected function searchFields(): array
{
    return [...parent::searchFields(), SearchField::make('customer_email')];
}

Arama kutusu yalnızca aranacak bir alan varsa görünür. SearchField iki biçimde kurulur:

Çağrı Okunan sütun
SearchField::make('email') email_search, baştan arama
SearchField::make('users.email', SearchMode::Prefix) users.email_search: birleştirilmiş (join) tablo
SearchField::make('users.name', SearchMode::Contains, 'ad_arama') users.ad_arama
SearchField::column('customer_search', SearchMode::Contains) Birden çok kaynaktan üretilmiş sütun

Alan listesinde düz bir metin, SearchField::make() ile aynıdır: 'email' yazmak SearchField::make('email') demektir.

Liste sayfasında arama

WithListing bileşeni alanları searchFields() ile verir ve sorguda applySearch() çağırır. applySearch() varsayılan olarak $this->search metnini ve searchFields() değerini kullanır:

use Acun\Ui\Search\SearchField;
use Acun\Ui\Search\SearchMode;

protected function searchFields(): array
{
    return [
        SearchField::make('name', SearchMode::Contains),
        SearchField::make('email'),
    ];
}

private function query(): Builder
{
    return $this->applySearch(Customer::query())
        ->when($this->status !== '', fn (Builder $q) => $q->where('status', $this->status))
        ->orderBy($this->sortField, $this->sortDirection);
}

İkinci bir arama kutusu (ör. bir "müşteri" filtresi) terimi ve alanları kendisi verir:

->when($this->customer !== '', fn (Builder $q) => $this->applySearch($q, $this->customer, [SearchField::make('users.email')]))

searchFields() tanımlamadan ve alan vermeden applySearch() çağıran bir bileşen açıklayıcı bir hata verir. Livewire dışında (rapor, controller) aynı arama için: app(SearchManager::class)->driver()->apply($query, $term, $fields). Liste sayfasının tamamı için Liste sayfası kılavuzuna bakın.

Sürücüler

Sürücü, aramanın nerede yapılacağını belirler. DataTable ve WithListing yalnızca Acun\Ui\Search\Contracts\SearchDriver sözleşmesini tanır:

public function apply(Builder $query, string $term, array $fields): Builder;
  • database (varsayılan): Yukarıda anlatılan arama sütunları.
  • Kendi sürücünüz: Bir arama motoru (Meilisearch, Typesense, Elasticsearch…) kullanacaksanız SearchDriver arayüzünü uygulayan bir sınıf yazın. Tablo kodu değişmez.

Bir arama motoru için en kolay yol şudur: motordan eşleşen kayıtların anahtarlarını alın, sonra aynı sorguyu whereKey() ile daraltın. Böylece kapsamlar, filtreler, yetki kuralları, sıralama ve sayfalama geçerli kalır:

namespace App\Search;

use Acun\Ui\Search\Contracts\SearchDriver;
use Illuminate\Database\Eloquent\Builder;

final class ElasticSearchDriver implements SearchDriver
{
    public function __construct(private readonly ProductSearchEngine $engine) {}

    public function apply(Builder $query, string $term, array $fields): Builder
    {
        if (trim($term) === '') {
            return $query;
        }

        return $query->whereKey($this->engine->keys($term, limit: 1000));
    }
}

Sürücü yazarken:

  • Terim yazıldığı gibi gelir; sadeleştirmeyi sürücü yapar.
  • Boş ya da yalnızca boşluktan oluşan terimde sorguya dokunmayın.
  • Sorgunun kendi koşullarını silmeyin; yalnızca koşul ekleyin.

Sürücüye bir ad verin ve varsayılan yapın. Ad yerine doğrudan sınıf adı da kullanılabilir:

// config/acun-ui.php
'search' => [
    'driver' => 'elastic',
    'drivers' => ['elastic' => App\Search\ElasticSearchDriver::class],
],

Varsayılan sürücüyü ortam değişkeniyle de seçebilirsiniz: ACUN_UI_SEARCH_DRIVER=elastic. Tek bir tablo ya da liste kendi sürücüsünü seçebilir:

use Acun\Ui\Search\Contracts\SearchDriver;
use Acun\Ui\Search\SearchManager;

protected function searchDriver(): SearchDriver
{
    return app(SearchManager::class)->driver('elastic');
}

Geliştirme ortamında denetim

Eksik bir arama sütunu, geliştirirken sessizce boş sonuç vermek yerine ne yapmanız gerektiğini söyleyen bir hata verir:

Acun UI search: the table "orders" has no "customer_email_search" column.
…
    Schema::table('orders', function (Blueprint $table) {
        $table->searchColumn('customer_email');
    });

Yerel (local) ve test (testing) ortamında veritabanı sürücüsü aramadan önce her arama sütununu denetler: sütun var mı ve hâlâ üretilen bir sütun mu. Birleştirilen (join) tablolar ve takma adlar da denetlenir. Denetim her tablonun şemasını istek başına bir kez okur. Üretim ortamında şema hiç okunmaz. acun-ui.search.verify_columns ile açıp kapatabilirsiniz: null yalnızca local ve testing, true ya da false her ortamda.

Yapılandırma

Arama ayarları config/acun-ui.php dosyasının search bölümündedir (bütün anahtarlar: Yapılandırma). Dosyayı php artisan vendor:publish --tag=acun-ui-config ile yayımlayın:

'search' => [
    'driver' => env('ACUN_UI_SEARCH_DRIVER', 'database'),
    'drivers' => [],              // 'ad' => SearchDriver sınıfı
    'suffix' => '_search',        // searchColumn('email') → email_search
    'length' => 255,              // varchar uzunluğu (0: text)
    'max_length' => 200,          // terimin en çok kaç karakteri kullanılır
    'max_words' => 10,            // terimin en çok kaç sözcüğü kullanılır
    'verify_columns' => null,     // null: local ve testing
],

Büyük tablo kontrol listesi

Büyük bir tabloda aramayı yayına almadan önce şunları kontrol edin:

  • Aranan her alanın bir searchColumn() sütunu var; baştan ya da tam aranan sütunlar indeksli.
  • Varsayılan mod Prefix. Contains yalnızca küçük tablolarda ya da PostgreSQL'de pg_trgm ile kullanılıyor.
  • Aynı aramada tek bir Contains alanı bütün grubu taramaya çevirir. Ad gibi alanlarda MySQL'de fullText: true ile SearchMode::FullText, PostgreSQL'de pg_trgm kullanılıyor.
  • EXPLAIN planı indeksi gösteriyor: MySQL type: range ve key: …_search_index, tam metinde type: fulltext; PostgreSQL Index Scan ya da Bitmap Index Scan; SQLite SEARCH … USING INDEX.
  • MySQL'de STORED/FULLTEXT ve PostgreSQL'de STORED sütun ekleyen migration tabloyu yeniden yazar. Yoğun olmayan bir saate ya da çevrimiçi bir şema aracına planlandı; PostgreSQL indeksi CONCURRENTLY ile kuruluyor.
  • Çok büyük tablolarda toplam sayım da maliyetlidir: acun-ui.datatable.pagination için simple ya da cursor değerini düşünün.
  • Arama sütunları select * ile geliyor: JSON'a çevrilen modellerde $hidden.
  • Yazım hatası toleransı ya da alaka sıralaması gerekiyorsa bir arama motoru için kendi sürücünüzü yazın; tablo kodu değişmez.

Daha fazlası

Acun UIİnsanlar için tasarlandı.