Limas operasi CRUD dari Bagian 2 membuat aplikasi kontak berfungsi, tetapi pengguna nyata jarang membaca daftar dari atas ke bawah — mereka mengetik sebagian nama di kotak pencarian, menyaring koleague dari satu domain, dan mengharapkan daftar yang tidak memanjang ribuan baris. Pada bagian ini kita mengembangkan ContactRepository dengan kemampuan tersebut, dan di sanalah materi paling halus proyek ini muncul: operator LIKE punya karakter wildcard yang harus di-escape agar kata kunci pengguna dicari literal, bukan ditafsirkan sebagai pola. Kita akhiri dengan paginasi LIMIT/OFFSET yang membuat daftar besar tetap ramah.

Tiga Kebutuhan Baru pada Lapisan Data #

Kebutuhan baru datang dari cara pengguna memakai aplikasi, dan setiap kebutuhan menjadi satu method repository dengan query inti yang jelas:

Method baruKebutuhan penggunaQuery inti
cari($kataKunci)mengetik sebagian namanama LIKE ? dengan escaping
filterByDomain($domain)melihat kontak satu domainemail LIKE '%@domain'
cariEmail($email)mengambil satu kontak persisWHERE email = ?
halaman($batas, $offset)daftar yang terbagi halamanLIMIT ? OFFSET ?
hitung()tahu total untuk jumlah halamanCOUNT(*)

Pencarian cari() dan pencarian cariEmail() memang terdengar mirip, tetapi keduanya berbeda kebutuhan: cari() mencocokkan sebagian nama dan boleh mengembalikan banyak hasil, sedangkan cariEmail() mencocokkan alamat email selengkap-lengkapnya, memanfaatkan index otomatis dari UNIQUE, dan berhenti pada satu baris. Method kedua inilah yang nanti dipakai Bagian 4 untuk mendeteksi email duplikat sebelum penyimpanan.

Method Baru pada ContactRepository #

Berikut tambahan pada class. Lima method CRUD dari Bagian 2 sama sekali tidak berubah; isi class lengkapnya tersedia pada berkas ContactRepository.php di bagian bawah halaman:

php
<?php

declare(strict_types=1);

final class ContactRepository
{
    // Constructor dan lima method CRUD dari Bagian 2
    // (simpan, cariId, semua, perbarui, hapus) tidak berubah.

    /**
     * Cari kontak yang namanya mengandung kata kunci.
     * Karakter wildcard LIKE (% dan _) pada kata kunci dicari literal.
     *
     * @return list<array<string, mixed>>
     */
    public function cari(string $kataKunci): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT id, nama, email, telepon, created_at
               FROM contacts
              WHERE nama LIKE ? ESCAPE '\\'
              ORDER BY nama"
        );
        $stmt->execute(['%' . $this->escapeLike($kataKunci) . '%']);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Ambil satu kontak berdasarkan alamat email persis; null bila tidak ada.
     *
     * @return array<string, mixed>|null
     */
    public function cariEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama, email, telepon, created_at
               FROM contacts
              WHERE email = ?'
        );
        $stmt->execute([$email]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Ambil seluruh kontak yang beralamat pada satu domain email.
     *
     * @return list<array<string, mixed>>
     */
    public function filterByDomain(string $domain): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT id, nama, email, telepon, created_at
               FROM contacts
              WHERE email LIKE ? ESCAPE '\\'
              ORDER BY email"
        );
        $stmt->execute(['%@' . $this->escapeLike($domain)]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Ambil satu halaman kontak untuk daftar bertahap.
     *
     * @return list<array<string, mixed>>
     */
    public function halaman(int $batas = 10, int $offset = 0): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama, email, telepon, created_at
               FROM contacts
              ORDER BY nama
              LIMIT ? OFFSET ?'
        );
        $stmt->execute([$batas, $offset]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Hitung jumlah seluruh kontak.
     */
    public function hitung(): int
    {
        return (int) $this->pdo
            ->query('SELECT COUNT(*) FROM contacts')
            ->fetchColumn();
    }

    /**
     * Escape karakter wildcard LIKE agar dicari sebagai karakter biasa.
     */
    private function escapeLike(string $nilai): string
    {
        return str_replace(
            ['\\', '%', '_'],
            ['\\\\', '\\%', '\\_'],
            $nilai
        );
    }
}

Tiga keputusan pada blok tersebut menjadi kunci bagian ini. Pertama, kata kunci tetap berjalan sebagai parameter ? — prepared statement menjaga dari SQL injection sebagaimana Bagian 2, dan karakter % yang kita tempelkan di sekitar kata kunci adalah bagian dari nilai parameter, bukan bagian dari string SQL. Kedua, klausa ESCAPE '\' memberi tahu SQLite bahwa garis miring terbalik adalah penanda “karakter berikutnya literal”. Ketiga, helper privat escapeLike() menjadi satu-satunya tempat logika escaping hidup, sehingga cari() dan filterByDomain() dijamin konsisten.

Pencarian Nama dengan LIKE #

Kita mulai dari perilaku dasar cari(). Operator LIKE pada SQLite mencocokkan sebagian teks dan, untuk huruf ASCII, tidak peduli kapital — perilaku yang pas untuk kotak pencarian:

php
<?php
require __DIR__ . '/ContactRepository.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));

$repo = new ContactRepository($pdo);

echo "== cari('sit') ==\n";
foreach ($repo->cari('sit') as $kontak) {
    echo "{$kontak['nama']} <{$kontak['email']}>\n";
}

echo "\n== cari('wijaya') ==\n";
foreach ($repo->cari('wijaya') as $kontak) {
    echo "{$kontak['nama']} <{$kontak['email']}>\n";
}

echo "\n== cari('xyz') ==\n";
echo count($repo->cari('xyz')) . " hasil\n";

Output yang Dihasilkan:

text
== cari('sit') ==
Siti Nurhaliza <[email protected]>

== cari('wijaya') ==
Andi Wijaya <[email protected]>

== cari('xyz') ==
0 hasil

Kata kunci sit yang huruf kecil seluruhnya tetap menemukan Siti Nurhaliza, dan wijaya menemukan Andi Wijaya meskipun posisinya di akhir nama — pattern %wijaya% mencocokkan teks di mana pun. Kata kunci tanpa pasangan mengembalikan array kosong, bukan null atau false, karena kontrak cari() memang “daftar hasil, bisa saja kosong”; pemanggil cukup foreach tanpa pemeriksaan tambahan.

Meng-escape Karakter Wildcard #

Masalah muncul ketika kata kunci pengguna mengandung % atau _. Keduanya adalah wildcard LIKE: % cocok dengan deretan karakter sepanjang apa pun, _ cocok dengan tepat satu karakter. Tanpa perlakuan, pencarian 50% berubah menjadi pola %50%% yang cocok dengan semua nama yang mengandung angka 50 — hasil yang menyesatkan pengguna dan, pada aplikasi lain, bisa jadi celah kebocoran data.

Solusinya dua bagian: escapeLike() menambahkan \ sebelum setiap karakter wildcard (termasuk \ itu sendiri), dan klausa ESCAPE '\' memberi makna pada penanda itu. Perbandingannya sebagai berikut:

Kata kunciPattern yang terkirimTanpa escaping akan cocok dengan
50%%50\%%semua nama yang mengandung 50
b_d%b\_d%Budi, Bado, b1d — garis bawah dianggap satu karakter apa pun

Mari kita buktikan dengan dua kontak yang sengaja memuat karakter wildcard pada namanya:

php
<?php
require __DIR__ . '/ContactRepository.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));

$repo = new ContactRepository($pdo);

$repo->simpan('Bayu Promo 50rb', '[email protected]', '081277700011');
$repo->simpan('Rani Diskon 50%', '[email protected]', '081377700022');

echo "== cari('50%') dengan escaping ==\n";
foreach ($repo->cari('50%') as $kontak) {
    echo "{$kontak['nama']}\n";
}

echo "\n== cari('b_d') dengan escaping ==\n";
echo count($repo->cari('b_d')) . " hasil\n";

Output yang Dihasilkan:

text
== cari('50%') dengan escaping ==
Rani Diskon 50%

== cari('b_d') dengan escaping ==
0 hasil

Hasil pertama tepat satu baris: hanya Rani Diskon 50% yang memuat 50% literal, sementara Bayu Promo 50rb tidak ikut terpilih meski mengandung 50. Hasil kedua nol karena tidak ada nama yang memuat huruf b, garis bawah, lalu d secara berurutan — padahal implementasi tanpa escaping akan mengembalikan Budi Santoso. Wildcard yang aktif memang berguna bila aplikasi sengaja menawarkannya sebagai fitur; jika tidak, pengguna berhak mendapatkan pencarian literal, dan keputusan itu sekarang berada di tangan kita, bukan di tangan kebetulan.

Filter Domain dan Pencarian Email Persis #

Filter domain menjawab kebutuhan “tampilkan semua kontak dari kantor yang sama”. Pattern %@ diikuti domain mencocokkan alamat yang berakhir persis pada domain tersebut, dan domain juga di-escape lewat helper yang sama agar karakter _ pada nama domain dicari literal:

php
<?php
require __DIR__ . '/ContactRepository.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));

$repo = new ContactRepository($pdo);

$repo->simpan('Eko Prasetyo', '[email protected]', '081399998888');
$repo->simpan('Fitri Handayani', '[email protected]', '085699997777');

echo "== filterByDomain('example.com') ==\n";
foreach ($repo->filterByDomain('example.com') as $kontak) {
    echo "{$kontak['nama']} <{$kontak['email']}>\n";
}

echo "\n== filterByDomain('kantor.example.com') ==\n";
foreach ($repo->filterByDomain('kantor.example.com') as $kontak) {
    echo "{$kontak['nama']} <{$kontak['email']}>\n";
}

echo "\n== filterByDomain('gmail.com') ==\n";
echo count($repo->filterByDomain('gmail.com')) . " hasil\n";

echo "\n== cariEmail('[email protected]') ==\n";
$budi = $repo->cariEmail('[email protected]');
echo "#{$budi['id']} {$budi['nama']}\n";

echo "\n== cariEmail('[email protected]') ==\n";
var_dump($repo->cariEmail('[email protected]'));

Output yang Dihasilkan:

text
== filterByDomain('example.com') ==
Andi Wijaya <[email protected]>
Budi Santoso <[email protected]>
Dewi Lestari <[email protected]>
Maya Putri <[email protected]>
Rian Hidayat <[email protected]>
Siti Nurhaliza <[email protected]>

== filterByDomain('kantor.example.com') ==
Eko Prasetyo <[email protected]>
Fitri Handayani <[email protected]>

== filterByDomain('gmail.com') ==
0 hasil

== cariEmail('[email protected]') ==
#1 Budi Santoso

== cariEmail('[email protected]') ==
NULL

Enam data awal beralamat example.com, dua kontak baru beralamat kantor.example.com, dan filter memisahkan keduanya dengan bersih — pattern %@example.com tidak menyambar subdomain karena menuntut teks berakhir tepat setelah domain. Domain yang tidak ada menghasilkan nol. Di bagian bawah contoh, cariEmail() memperlihatkan kontrak ?array yang sama dengan cariId(): satu kontak atau null. Bedanya, pencarian ini menumpang index sqlite_autoindex_contacts_1 milik UNIQUE, jadi biayanya nyaris konstan berapa pun jumlah baris.

Paginasi dengan LIMIT dan OFFSET #

Daftar terakhir yang kita butuhkan adalah tampilan bertahap. halaman($batas, $offset) mengambil paling banyak $batas baris dimulai dari posisi $offset, dan hitung() memberi tahu total baris agar jumlah halaman bisa dihitung dengan ceil():

php
<?php
require __DIR__ . '/ContactRepository.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));

$repo = new ContactRepository($pdo);

$repo->simpan('Eko Prasetyo', '[email protected]', '081399998888');
$repo->simpan('Fitri Handayani', '[email protected]', '085699997777');

$total = $repo->hitung();
$batas = 3;
$totalHalaman = (int) ceil($total / $batas);

echo "Total kontak: $total, per halaman: $batas, jadi $totalHalaman halaman\n";
for ($halaman = 1; $halaman <= $totalHalaman; $halaman++) {
    $offset = ($halaman - 1) * $batas;
    echo "\n== Halaman $halaman (offset $offset) ==\n";
    foreach ($repo->halaman($batas, $offset) as $kontak) {
        echo "  {$kontak['nama']}\n";
    }
}

echo "\n== halaman(3, 9): offset melewati akhir data ==\n";
echo count($repo->halaman(3, 9)) . " baris\n";

Output yang Dihasilkan:

text
Total kontak: 8, per halaman: 3, jadi 3 halaman

== Halaman 1 (offset 0) ==
  Andi Wijaya
  Budi Santoso
  Dewi Lestari

== Halaman 2 (offset 3) ==
  Eko Prasetyo
  Fitri Handayani
  Maya Putri

== Halaman 3 (offset 6) ==
  Rian Hidayat
  Siti Nurhaliza

== halaman(3, 9): offset melewati akhir data ==
0 baris

Delapan kontak terbagi menjadi tiga halaman 3 + 3 + 2, dan offset di luar data mengembalikan array kosong tanpa galat. Kedua angka batas dan offset berjalan sebagai parameter ? seperti nilai lainnya. Satu catatan skala penting: OFFSET memaksa SQLite melompati baris satu per satu dari awal, sehingga halaman ke-500 pada tabel berjuta baris tetap membaca setengah juta baris sebelum mengembalikan hasil. Untuk daftar seukuran aplikasi ini offset sudah memadai; ketika data membesar, pola yang lebih hemat adalah pagination berbasis cursor — misalnya WHERE nama > ? dengan nilai nama terakhir halaman sebelumnya — karena pola tersebut langsung menuju posisi lewat index.

Cara Menjalankan Contoh #

Contoh bagian ini memakai ContactRepository.php versi terbaru dari folder ini serta schema.sql dari Bagian 1:

text
aplikasi-kontak/
├── bagian-1-desain-data/
│   └── schema.sql
└── bagian-3-pencarian-dan-filtering/
    ├── ContactRepository.php
    └── uji-cari.php

Simpan salah satu snippet sebagai uji-cari.php di dalam folder bagian-3-pencarian-dan-filtering, lalu jalankan:

bash
php uji-cari.php

Perhatikan bahwa ContactRepository.php pada folder ini menggantikan versi Bagian 2 — keduanya berisi class dengan nama sama, jadi pastikan require menunjuk ke versi yang benar saat kedua bagian dicoba bergantian. Bagian 4 dan Bagian 5 akan selalu memakai versi terbaru ini.

Apa yang Berubah #

Repository tumbuh dari lima menjadi sepuluh method publik, dan satu helper privat masuk:

  • cari($kataKunci) — pencarian nama dengan LIKE yang di-escape, urut nama.
  • cariEmail($email) — pencarian email persis di atas index UNIQUE, mengembalikan ?array.
  • filterByDomain($domain) — filter domain email dengan escaping yang sama.
  • halaman($batas, $offset) — satu halaman daftar urut nama lewat LIMIT/OFFSET.
  • hitung() — total baris untuk menghitung jumlah halaman.
  • escapeLike() — helper privat yang memusatkan logika wildcard.

Lima method CRUD dari Bagian 2 tidak disentuh sama sekali; itulah manfaat memusatkan SQL pada satu class — perubahan fitur tidak menular ke kode yang tidak membutuhkannya.

Ringkasan #

Pencarian yang benar ternyata bukan sekadar menempel % di kiri-kanan kata kunci: wildcard harus di-escape agar maksud pengguna dihormati, domain difilter dengan pattern yang tidak menyambar subdomain, dan paginasi menghitung halaman dari total baris. Semua nilai dari luar tetap berjalan sebagai parameter prepared statement, dan setiap method baru pulang dengan kontrak tipe yang tegas. Data kini bisa dicari dan disaring dengan nyaman — tinggal satu kelemahan yang kita toleransi sejak Bagian 2: pesan galat mentah dari database. Bagian 4 membangun lapisan validasi yang mencegah data buruk masuk sejauh mungkin dan menjadikan constraint sebagai jaring pengaman terakhir.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ContactRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel contacts.
 *
 * Seluruh query SQL aplikasi kontak dipusatkan di class ini.
 * Koneksi PDO disuntikkan lewat constructor, sehingga pemanggil-lah
 * yang menentukan database yang dipakai: berkas di disk atau database
 * sementara `sqlite::memory:`.
 *
 * Versi Bagian 3 menambahkan pencarian nama yang aman terhadap
 * karakter wildcard LIKE, filter domain email, pencarian email persis,
 * serta paginasi LIMIT/OFFSET.
 */
final class ContactRepository
{
    public function __construct(private PDO $pdo)
    {
        // Pemeriksaan foreign key SQLite nonaktif secara default.
        // Menyalakannya di sini adalah kebiasaan murah yang menjaga
        // integritas begitu tabel berelasi ditambahkan kelak.
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Simpan kontak baru, kembalikan id baris yang baru dibuat.
     */
    public function simpan(string $nama, string $email, string $telepon): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO contacts (nama, email, telepon) VALUES (?, ?, ?)'
        );
        $stmt->execute([$nama, $email, $telepon]);

        return (int) $this->pdo->lastInsertId();
    }

    /**
     * Ambil satu kontak berdasarkan id; null bila tidak ditemukan.
     *
     * @return array<string, mixed>|null
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama, email, telepon, created_at
               FROM contacts
              WHERE id = ?'
        );
        $stmt->execute([$id]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Ambil satu kontak berdasarkan alamat email persis; null bila tidak ada.
     *
     * @return array<string, mixed>|null
     */
    public function cariEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama, email, telepon, created_at
               FROM contacts
              WHERE email = ?'
        );
        $stmt->execute([$email]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Ambil seluruh kontak, urut berdasarkan nama.
     *
     * @return list<array<string, mixed>>
     */
    public function semua(): array
    {
        return $this->pdo
            ->query('SELECT id, nama, email, telepon, created_at
                       FROM contacts
                      ORDER BY nama')
            ->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Perbarui seluruh kolom data satu kontak.
     * Mengembalikan false bila id tidak ditemukan.
     */
    public function perbarui(int $id, string $nama, string $email, string $telepon): bool
    {
        $stmt = $this->pdo->prepare(
            'UPDATE contacts
                SET nama = ?, email = ?, telepon = ?
              WHERE id = ?'
        );
        $stmt->execute([$nama, $email, $telepon, $id]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Hapus satu kontak. Mengembalikan false bila id tidak ditemukan.
     */
    public function hapus(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM contacts WHERE id = ?');
        $stmt->execute([$id]);

        return $stmt->rowCount() > 0;
    }

    /**
     * Cari kontak yang namanya mengandung kata kunci.
     * Karakter wildcard LIKE (% dan _) pada kata kunci dicari literal.
     *
     * @return list<array<string, mixed>>
     */
    public function cari(string $kataKunci): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT id, nama, email, telepon, created_at
               FROM contacts
              WHERE nama LIKE ? ESCAPE '\\'
              ORDER BY nama"
        );
        $stmt->execute(['%' . $this->escapeLike($kataKunci) . '%']);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Ambil seluruh kontak yang beralamat pada satu domain email.
     *
     * @return list<array<string, mixed>>
     */
    public function filterByDomain(string $domain): array
    {
        $stmt = $this->pdo->prepare(
            "SELECT id, nama, email, telepon, created_at
               FROM contacts
              WHERE email LIKE ? ESCAPE '\\'
              ORDER BY email"
        );
        $stmt->execute(['%@' . $this->escapeLike($domain)]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Ambil satu halaman kontak untuk daftar bertahap.
     *
     * @return list<array<string, mixed>>
     */
    public function halaman(int $batas = 10, int $offset = 0): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama, email, telepon, created_at
               FROM contacts
              ORDER BY nama
              LIMIT ? OFFSET ?'
        );
        $stmt->execute([$batas, $offset]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Hitung jumlah seluruh kontak.
     */
    public function hitung(): int
    {
        return (int) $this->pdo
            ->query('SELECT COUNT(*) FROM contacts')
            ->fetchColumn();
    }

    /**
     * Escape karakter wildcard LIKE agar dicari sebagai karakter biasa.
     */
    private function escapeLike(string $nilai): string
    {
        return str_replace(
            ['\\', '%', '_'],
            ['\\\\', '\\%', '\\_'],
            $nilai
        );
    }
}