Memindahkan data antar sistem — dari CSV mitra ke database, dari database ke laporan, dari satu format ke format lain — tampak seperti pekerjaan sepele, tetapi praktiknya penuh jebakan: baris rusak, duplikasi akibat proses yang terulang, dan kegagalan yang tidak diketahui siapa pun hingga laporan bulanan salah. Chapter ini merakit seluruh teknik Section ini menjadi alur kerja yang disiplin: import mengikuti tahapan parse → validasi → transformasi → muat, sedangkan export mengikuti query → format → tulis. Kita juga memenuhi tiga syarat kelas produksi: idempotensi agar proses boleh diulang tanpa duplikat, mode dry-run untuk menguji tanpa menulis, dan laporan baris yang ditolak agar kegagalan dapat ditelusuri.
Alur Import: Parse, Validasi, Transformasi, Muat #
Import yang baik tidak pernah melompat dari berkas langsung ke database. Setiap tahap punya tanggung jawab tunggal: parse mengubah teks menjadi array, validasi menolak baris yang melanggar aturan, transformasi membersihkan dan mengubah bentuk (teknik dari Chapter Data Transformation ), dan muat menulis hasilnya ke tujuan akhir. Contoh berikut menjalankan keempat tahap untuk berkas CSV pelanggan menuju SQLite:
<?php
$berkasCsv = "/tmp/pelanggan-etl.csv";
file_put_contents(
$berkasCsv,
"email,nama,saldo\n" .
"[email protected],Budi Santoso,150000\n" .
"[email protected],Siti Rahayu,275000\n" .
"email-tidak-valid,Andi Wijaya,90000\n"
);
// Tahap 1 — parse: ubah CSV menjadi array asosiatif
$masuk = fopen($berkasCsv, "rb");
$header = fgetcsv($masuk, null, ",", "\"", "\\");
$barisMentah = [];
while (($kolom = fgetcsv($masuk, null, ",", "\"", "\\")) !== false) {
$barisMentah[] = array_combine($header, $kolom);
}
fclose($masuk);
// Tahap 2 (validasi) + Tahap 3 (transformasi)
$siapMuat = [];
foreach ($barisMentah as $baris) {
$email = mb_strtolower(trim($baris["email"]));
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
continue; // baris ditolak; pencatatannya dibahas di bagian laporan error
}
$siapMuat[] = [
"email" => $email,
"nama" => mb_convert_case(trim($baris["nama"]), MB_CASE_TITLE),
"saldo" => (int) $baris["saldo"],
];
}
// Tahap 4 — load: simpan ke SQLite
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pelanggan (email TEXT PRIMARY KEY, nama TEXT, saldo INTEGER)");
$stmt = $pdo->prepare("INSERT INTO pelanggan (email, nama, saldo) VALUES (?, ?, ?)");
foreach ($siapMuat as $baris) {
$stmt->execute([$baris["email"], $baris["nama"], $baris["saldo"]]);
}
printf(
"Mentah: %d, dimuat: %d, ditolak: %d\n",
count($barisMentah),
count($siapMuat),
count($barisMentah) - count($siapMuat)
);
foreach ($pdo->query("SELECT nama, saldo FROM pelanggan ORDER BY saldo DESC") as $row) {
printf("%s — Rp%d\n", $row["nama"], $row["saldo"]);
}
unlink($berkasCsv);Output yang Dihasilkan:
Mentah: 3, dimuat: 2, ditolak: 1
Siti Rahayu — Rp275000
Budi Santoso — Rp150000Baris ketiga berisi email yang tidak valid sehingga ditolak pada tahap validasi — perhatikan bahwa proses tetap berlanjut untuk dua baris lainnya, dan laporan akhir menunjukkan hitungan ketiga kategori. Kolom saldo berubah dari string CSV menjadi integer, dan email diseragamkan menjadi huruf kecil sebelum menyinggah di $siapMuat. Pada berkas jutaan baris, gantilah pembacaan fgetcsv() dengan pola streaming dan tulis per batch dari Chapter Streaming Large Files
serta Batch Processing
— struktur empat tahapnya tidak berubah.
Menjalankan Import yang Idempoten #
Import di dunia nyata selalu bisa terulang: cron menyala dua kali, proses diulang setelah perbaikan, atau mitra mengirim ulang berkas yang sama. Import idempoten menghasilkan keadaan akhir yang sama berapa pun kali dijalankan — kuncinya adalah natural key (kolom yang mengidentifikasi baris secara alami, misalnya email) dikombinasikan dengan upsert: baris baru disisipkan, baris lama diperbarui. SQLite menyediakannya lewat INSERT ... ON CONFLICT ... DO UPDATE, sementara MySQL memakai INSERT ... ON DUPLICATE KEY UPDATE dan PostgreSQL memakai INSERT ... ON CONFLICT DO UPDATE juga:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pelanggan (email TEXT PRIMARY KEY, nama TEXT, saldo INTEGER)");
function imporPelanggan(PDO $pdo, array $baris): void
{
$stmt = $pdo->prepare(
"INSERT INTO pelanggan (email, nama, saldo) VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
nama = excluded.nama,
saldo = excluded.saldo"
);
foreach ($baris as $b) {
$stmt->execute([$b["email"], $b["nama"], $b["saldo"]]);
}
}
$imporPertama = [
["email" => "[email protected]", "nama" => "Budi", "saldo" => 150000],
["email" => "[email protected]", "nama" => "Siti", "saldo" => 275000],
];
$imporKedua = [
["email" => "[email protected]", "nama" => "Budi Santoso", "saldo" => 175000],
["email" => "[email protected]", "nama" => "Andi", "saldo" => 90000],
];
imporPelanggan($pdo, $imporPertama);
imporPelanggan($pdo, $imporKedua);
echo "Total baris: ", $pdo->query("SELECT COUNT(*) FROM pelanggan")->fetchColumn(), "\n";
foreach ($pdo->query("SELECT email, nama, saldo FROM pelanggan ORDER BY email") as $row) {
printf("%s|%s|%d\n", $row["email"], $row["nama"], $row["saldo"]);
}Output yang Dihasilkan:
Total baris: 3
[email protected]|Andi|90000
[email protected]|Budi Santoso|175000
[email protected]|Siti|275000Syarat utamanya: natural key harus menjadi PRIMARY KEY atau memiliki UNIQUE constraint — tanpa itu, ON CONFLICT tidak punya konflik yang bisa dideteksi dan baris tetap dobel. Pada output, total baris tetap tiga meskipun dua import berisi empat baris: email Budi yang muncul di kedua import diperbarui (nama dan saldo versi terbaru), bukan disisipkan lagi. Bandingkan dengan INSERT polos yang akan menghasilkan lima baris dan menumbuhkan duplikat setiap pengulangan.
Mode Dry-Run #
Sebelum menjalankan import sungguhan pada data penting, kita perlu tahu apa yang akan terjadi tanpa menulis apa pun. Mode dry-run menjalankan seluruh tahap — parse, validasi, transformasi — lalu berhenti sebelum tahap muat dan hanya melaporkan statistik. Cukup satu flag boolean yang diperiksa pada titik penulisan:
<?php
function imporPelanggan(array $baris, bool $dryRun): array
{
$statistik = ["valid" => 0, "ditolak" => 0];
foreach ($baris as $b) {
$email = mb_strtolower(trim($b["email"]));
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$statistik["ditolak"]++;
continue;
}
$statistik["valid"]++;
if (!$dryRun) {
// tulis baris ke database di sini — dilewati saat dry-run
}
}
return $statistik;
}
$calon = [
["email" => "[email protected]", "nama" => "Budi"],
["email" => "[email protected]", "nama" => "Siti"],
["email" => "budi#example.com", "nama" => "Salah Email"],
];
$hasil = imporPelanggan($calon, true);
printf(
"DRY RUN — valid: %d, ditolak: %d, baris tertulis: 0\n",
$hasil["valid"],
$hasil["ditolak"]
);Output yang Dihasilkan:
DRY RUN — valid: 2, ditolak: 1, baris tertulis: 0Perhatikan bahwa validasi tetap berjalan penuh saat dry-run — itulah gunanya: angka valid dan ditolak dari berkas sungguhan memberi keyakinan sebelum data benar-benar disentuh. Pada skrip CLI, flag ini biasanya datang dari argumen --dry-run lewat $argv; pada job scheduler, flag bisa dibaca dari environment atau konfigurasi. Setelah angka dry-run masuk akal, jalankan ulang tanpa flag dan statistik yang sama menjadi bukti bahwa eksekusi sungguhan sesuai perkiraan.
Laporan Baris yang Ditolak #
Menolak baris tanpa mencatatnya sama buruknya dengan menelan error: data hilang diam-diam. Setiap baris yang ditolak perlu dituliskan ke berkas laporan lengkap dengan nomor baris asal dan alasannya, sehingga pemilik data dapat memperbaiki sumber dan mengimpor ulang hanya bagian yang gagal:
<?php
$berkasCsv = "/tmp/impor-pelanggan.csv";
file_put_contents(
$berkasCsv,
"email,nama\n" .
"[email protected],Budi\n" .
"budi#example.com,Salah Email\n" .
",Tanpa Nama\n"
);
$berkasTolak = "/tmp/impor-ditolak.csv";
$masuk = fopen($berkasCsv, "rb");
$keluar = fopen($berkasTolak, "wb");
fputcsv($keluar, ["baris", "email", "alasan"], ",", "\"", "\\");
$header = fgetcsv($masuk, null, ",", "\"", "\\");
$nomorBaris = 1;
$jumlahDiterima = 0;
while (($kolom = fgetcsv($masuk, null, ",", "\"", "\\")) !== false) {
$nomorBaris++;
$data = array_combine($header, $kolom);
$email = trim($data["email"]);
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
fputcsv($keluar, [$nomorBaris, $email, "format email tidak valid"], ",", "\"", "\\");
continue;
}
$jumlahDiterima++;
}
fclose($masuk);
fclose($keluar);
printf("Diterima: %d, ditolak: %d\n", $jumlahDiterima, $nomorBaris - 1 - $jumlahDiterima);
echo file_get_contents($berkasTolak);
unlink($berkasCsv);
unlink($berkasTolak);Output yang Dihasilkan:
Diterima: 1, ditolak: 2
baris,email,alasan
3,budi#example.com,"format email tidak valid"
4,,"format email tidak valid"Berkas laporan sendiri berformat CSV dengan header baris,email,alasan, sehingga dapat dibuka di aplikasi spreadsheet dan dikirim balik ke pemilik data. Nomor baris dihitung dari awal berkas termasuk header, jadi 3 dan 4 merujuk pada posisi baris persis seperti yang dilihat di editor teks. Perhatikan pula pada output bahwa fputcsv() memberi tanda kutip pada kolom yang mengandung spasi atau koma — perilaku bawaan PHP untuk menjaga keabsahan CSV, bukan kesalahan.
Alur Export: Query, Format, Tulis #
Export adalah cermin import: query mengambil data, format mengubahnya ke format tujuan, tulis menyimpannya keluar. Kuncinya di pembacaan bertahap — fetch() satu baris demi satu baris sambil menulis dengan fputcsv(), bukan fetchAll() yang menampung seluruh tabel di memori:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pelanggan (email TEXT PRIMARY KEY, nama TEXT, saldo INTEGER)");
$stmt = $pdo->prepare("INSERT INTO pelanggan (email, nama, saldo) VALUES (?, ?, ?)");
foreach ([
["[email protected]", "Budi Santoso", 150000],
["[email protected]", "Siti Rahayu", 275000],
["[email protected]", "Andi Wijaya", 90000],
] as $row) {
$stmt->execute($row);
}
$berkasExport = "/tmp/pelanggan-export.csv";
$keluaran = fopen($berkasExport, "wb");
fputcsv($keluaran, ["email", "nama", "saldo"], ",", "\"", "\\");
$query = $pdo->query("SELECT email, nama, saldo FROM pelanggan ORDER BY saldo DESC");
while ($baris = $query->fetch(PDO::FETCH_ASSOC)) {
fputcsv($keluaran, $baris, ",", "\"", "\\");
}
fclose($keluaran);
echo file_get_contents($berkasExport);
unlink($berkasExport);Output yang Dihasilkan:
email,nama,saldo
[email protected],"Siti Rahayu",275000
[email protected],"Budi Santoso",150000
[email protected],"Andi Wijaya",90000Query mengurutkan hasil dengan ORDER BY sehingga keluaran deterministik — penting agar berkas export dapat dibandingkan antar-generasi dan diuji otomatis. Untuk tabel besar, tambahkan keyset pagination dari Chapter Batch Processing
agar query tidak menahan seluruh hasil sekaligus, dan pertimbangkan INSERT ... SELECT di sisi database ketika kedua ujung sama-sama berupa tabel database — PHP bahkan tidak perlu melihat datanya.
Konversi Format CSV ke JSON #
Konversi format adalah kasus khusus export di mana sumber dan tujuan sama-sama berkas: dibaca streaming dari satu format, ditulis ke format lain. CSV ke JSON adalah kombinasi paling umum untuk menyiapkan data bagi frontend atau API:
<?php
$berkasCsv = "/tmp/menu.csv";
file_put_contents($berkasCsv, "kode,nama,harga\nM001,Es Kopi Susu,22000\nM002,Teh Tarik,18000\n");
$masuk = fopen($berkasCsv, "rb");
$header = fgetcsv($masuk, null, ",", "\"", "\\");
$menu = [];
while (($kolom = fgetcsv($masuk, null, ",", "\"", "\\")) !== false) {
$item = array_combine($header, $kolom);
$item["harga"] = (int) $item["harga"];
$menu[] = $item;
}
fclose($masuk);
unlink($berkasCsv);
echo json_encode($menu, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";Output yang Dihasilkan:
[
{
"kode": "M001",
"nama": "Es Kopi Susu",
"harga": 22000
},
{
"kode": "M002",
"nama": "Teh Tarik",
"harga": 18000
}
]Koersi (int) pada kolom harga menjaga tipe angka di JSON — tanpa itu, "harga": "22000" akan tampil sebagai string yang harus dibersihkan lagi oleh konsumen. Flag JSON_UNESCAPED_UNICODE menjaga karakter non-ASCII tetap terbaca manusia, dan JSON_PRETTY_PRINT dipakai di sini demi keterbacaan contoh; untuk pertukaran mesin, JSON satu baris tanpa pretty print lebih ramping. Arah sebaliknya (JSON ke CSV) mengikuti pola yang sama dengan json_decode() di posisi parse lalu loop fputcsv() di posisi tulis.
Ringkasan #
Alur import dan export yang disiplin terdiri dari tahapan yang dapat disebut satu per satu: parse, validasi, transformasi, muat untuk arah masuk; query, format, tulis untuk arah keluar. Idempotensi lewat natural key dan upsert membuat proses aman diulang, dry-run membuat perubahan dapat dipratinjau, dan laporan baris ditolak membuat kegagalan dapat ditelusuri hingga ke sumbernya. Satu hal yang belum kita bahas dan sering merusak pertukaran data secara diam-diam adalah teks itu sendiri — byte, karakter, dan encoding — yang menjadi pembuka Section Internationalization dan Encoding .