Soft Deletes vs Unique Constraints: Bug SKU Berganda yang

Seorang pengurus produk pernah bertanya soalan mudah kepada saya: “Mengapakah skrin gudang menunjukkan dua produk berbeza dengan SKU yang sama?”

Saya pasti ia tidak mungkin berlaku. Jadual products mempunyai unique index pada sku. Borang itu mengesahkan keunikan. Kami ada ujian. Namun, di situ ada: dua baris aktif, SKU sama, harga berbeza, setiap satu mengumpul pergerakan stoknya sendiri selama berbulan-bulan.

Bug itu bukan dalam controller atau query. Ia terletak pada satu baris migration yang nampak sangat munasabah dalam code review. Catatan ini ialah kisah baris itu, mengapa ia gagal, dan corak yang saya guna sekarang setiap kali soft deletes dan uniqueness bertemu dalam aplikasi perniagaan Laravel.

Masalahnya: soft deletes dan unique keys tidak sependapat

Soft deletes hampir wajib dalam sistem ERP dan CRM. Anda jarang ingin memadam produk, pelanggan atau pembekal secara fizikal, kerana invois, pesanan dan audit logs masih merujuk kepadanya. Laravel memudahkan ini dengan trait SoftDeletes dan lajur deleted_at.

Kunci perniagaan sama lazim: SKU produk, nombor cukai pelanggan, kod pembekal, nombor lencana pekerja. Semua ini mesti unik, dan tempat paling jelas untuk menguatkuasakan syarat itu ialah pangkalan data.

Di sinilah kedua-duanya bertembung. Versi pertama migration kami kelihatan seperti ini:

Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('sku', 64)->unique();
$table->string('name');
$table->softDeletes();
$table->timestamps();
});

Ia berfungsi sehingga pengguna memadam produk yang dicipta secara tersilap, lalu cuba menciptanya semula dengan data yang betul. Baris yang dipadam masih menyimpan SKU, jadi insert gagal. Peraturan pengesahan juga berpendapat sama, kerana Rule::unique(‘products’, ‘sku’) memeriksa jadual, bukan model Eloquent, jadi baris yang di-soft-delete juga dikira.

Sebuah ticket masuk: “Saya tidak boleh menggunakan semula SKU selepas memadam produk.” Pembetulan yang dihantar ialah ini:

$table->dropUnique(['sku']);
$table->unique(['sku', 'deleted_at']);

Hujahnya kedengaran kukuh: baris yang dipadam mempunyai timestamp dalam deleted_at, baris aktif mempunyai NULL, jadi pasangan (sku, deleted_at) sepatutnya unik untuk produk aktif dan bebas untuk produk yang dipadam.

Hujah itu salah.

Mengapa composite index tidak memberi kesan kepada baris aktif

Dalam SQL, NULL tidak sama dengan NULL. Dokumentasi MySQL menyatakan dengan jelas: index UNIQUE membenarkan berbilang nilai NULL untuk lajur yang boleh mengandungi NULL. PostgreSQL berkelakuan sama secara lalai.

Jadi, untuk setiap produk aktif, index nampak (sku, NULL), dan dua row yang ada (‘ABC-100’, NULL) tidak dianggap duplikat. Composite index hanya menghalang dua row yang dipadam dengan SKU sama dan timestamp penghapusan yang sama persis, iaitu satu kes yang memang tiada siapa peduli.

Kenapa ambil masa setahun untuk perasan? Sebab pengesahan borang masih dapat kesan kebanyakan duplikat. Kerosakan sebenar datang daripada laluan yang langkau borang:

  • import CSV setiap malam yang guna upsert() dan di-key pada id, bukan sku
  • endpoint API yang ditambah kemudian oleh pasukan lain, yang hanya sahkan format
  • dua pengguna simpan produk baharu yang sama pada masa yang sama (pengesahan lulus untuk kedua-duanya, sebab belum ada row yang wujud lagi)

Pengesahan ialah ciri pengalaman pengguna. Constraint pangkalan data ialah jaminannya. Kami telah buang jaminan itu secara senyap-senyap.

Pembaikan: jadikan pangkalan data faham “aktif”

Tujuannya mudah dinyatakan: SKU mesti unik dalam kalangan row yang deleted_at IS NULL. Ada dua cara bagus untuk nyatakan itu, bergantung pada pangkalan data anda.

PostgreSQL: partial unique index

PostgreSQL menyokong partial index, yang memang menepati peraturan ini. Saya guna raw statement dalam migration supaya niatnya jelas:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
public function up(): void
{
DB::statement(
'CREATE UNIQUE INDEX products_sku_active_unique
ON products (sku)
WHERE deleted_at IS NULL'
);
}

public function down(): void
{
DB::statement('DROP INDEX IF EXISTS products_sku_active_unique');
}
};

Row aktif mesti ada SKU yang unik. Row yang dipadam langsung tidak masuk dalam index, jadi anda boleh padam dan cipta semula SKU sesering yang anda mahu.

MySQL / MariaDB: generated column

MySQL tiada partial index, tapi ia ada generated column, dan schema builder Laravel menyokongnya melalui virtualAs() dan storedAs(). Triknya ialah cipta column yang menyimpan SKU untuk row aktif dan NULL untuk row yang dipadam, kemudian letakkan unique index biasa padanya. Sebab banyak NULL dibenarkan, row yang dipadam tidak pernah konflik.

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('products', function (Blueprint $table) {
$table->dropUnique(['sku', 'deleted_at']);

$table->string('active_sku', 64)
->nullable()
->storedAs('CASE WHEN deleted_at IS NULL THEN sku END');

$table->unique('active_sku');
});
}

public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropUnique(['active_sku']);
$table->dropColumn('active_sku');
$table->unique(['sku', 'deleted_at']);
});
}
};

Beberapa catatan daripada menjalankan ini di production:

  • Saya guna CASE WHEN … END berbanding IF() MySQL supaya expression yang sama dapat dibaca secara natural pada engine lain.
  • storedAs() makan sedikit ruang cakera, tapi ia pilihan yang paling tidak mengejutkan. InnoDB juga boleh membuat index pada virtual column; pilih satu dan kekal konsisten.
  • Jangan sesekali tulis ke active_sku dari aplikasi. Jangan letakkan ia dalam $fillable dan layankannya sebagai read-only.

Bersihkan data sebelum anda tambah constraint

Kalau bug ini sudah pun terjadi, unique index baharu akan gagal dibina. Cari duplikat itu dahulu:

SELECT sku, COUNT(*) AS active_rows, GROUP_CONCAT(id) AS product_ids
FROM products
WHERE deleted_at IS NULL
GROUP BY sku
HAVING COUNT(*) > 1;

(GROUP_CONCAT ialah untuk MySQL; pada PostgreSQL, gunakan string_agg(id::text, ‘,’) .)

Menggabungkan rekod duplikat ialah keputusan perniagaan, bukan keputusan teknikal. Ada pihak yang perlu memutuskan baris mana yang kekal dan ke mana pergerakan stok, pesanan terbuka serta sejarah harga akan ditempatkan. Letakkan proses itu dalam migrasi data yang disemak, bukan sebagai pembaikan pantas pada production.

Kekalkan validation, tapi pastikan ia selari dengan database

Validation masih penting, kerana mesej mesra lebih baik daripada ralat 500. Rule Unique dalam Laravel ada method withoutTrashed() yang mengabaikan baris soft-deleted, jadi ia kini sepadan sepenuhnya dengan rule database:

use Illuminate\Validation\Rule;

public function rules(): array
{
$product = $this->route('product');

return [
'sku' => [
'required',
'string',
'max:64',
Rule::unique('products', 'sku')
->withoutTrashed()
->ignore($product?->id),
],
'name' => ['required', 'string', 'max:255'],
];
}

Validation dan constraint kini menerangkan rule yang sama. Validation memberikan mesej mesra; constraint menghalang race, import dan endpoint pasukan lain.

Restoring kini menjadi konflik

Di sinilah bahagian yang ramai orang lupa. Apabila SKU boleh digunakan semula, produk yang dipadam boleh berkongsi SKU dengan produk aktif yang lebih baharu. Memanggil restore() pada produk lama akan melanggar constraint. Sejak Laravel 10, pelanggaran unique akan throw Illuminate\Database\UniqueConstraintViolationException, jadi anda boleh menukar ralat itu kepada mesej yang jelas:

use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Validation\ValidationException;

public function restore(Product $product): RedirectResponse
{
try {
$product->restore();
} catch (UniqueConstraintViolationException $e) {
throw ValidationException::withMessages([
'sku' => "Another active product already uses SKU {$product->sku}. "
. 'Change one of the SKUs before restoring.',
]);
}

return back()->with('status', 'Product restored.');
}

Route model binding perlukan ->withTrashed() pada route supaya ia dapat mencari produk yang dipadam.

Perangkap biasa

Case dan whitespace. Pada collation yang tidak sensitif huruf besar/kecil, abc-100 dan ABC-100 akan bertembung; pada PostgreSQL, ia tidak bertembung. Tentukan maksud “SKU yang sama” dan normalisasikan semasa simpan (contohnya trim dan uppercase dalam mutator) supaya database dan perniagaan selari.

Pengujian pada engine berbeza. Jika test anda berjalan pada SQLite dan production berjalan pada MySQL, test constraint anda tidak banyak membuktikan apa-apa. Jalankan sekurang-kurangnya test schema dan integrity pada engine sebenar.

Penulisan pukal tidak melalui Eloquent. DB::table()->insert(), upsert() dan raw import tidak pernah menyentuh form request anda. Itulah sebabnya rule ini mesti berada dalam database.

Business key lain. SKU ialah contoh, tetapi kesilapan yang sama boleh bersembunyi dalam nombor cukai pelanggan, kod pembekal, e-mel pengguna dan nombor dokumen. Grep migration anda untuk unique([ bersama deleted_at .

Foreign key pada business key. Jika table lain merujuk sku dan bukannya id, menggunakan semula SKU secara senyap akan mengubah rujukan sejarah kepada produk baharu. Rujuk primary key.

Ringkasan

  • Indeks unik pada (column, deleted_at) tidak melindungi baris aktif, kerana nilai NULL tidak pernah bertindih.
  • Pada PostgreSQL, gunakan indeks unik separa dengan WHERE deleted_at IS NULL.
  • Pada MySQL, buat indeks pada generated column yang bernilai NULL untuk baris yang dipadam.
  • Gunakan Rule::unique(…)->withoutTrashed() supaya pengesahan sepadan dengan kekangan.
  • Tangani UniqueConstraintViolationException semasa restore, dan bersihkan pendua sedia ada sebelum menambah indeks.

Pengajaran yang saya ambil lebih besar daripada soft deletes: bila pembaikan membuatkan tiket hilang, tanya jaminan apa yang dibuangnya. Jaminan kami tersimpan dalam satu baris, dan tiada siapa perasan bila ia berubah.

Business key mana dalam app anda yang anda berani bertaruh benar-benar unik hari ini, dan adakah anda sudah menyemaknya terhadap baris aktif?


Sumber Rujukan:

  • Artikel asal: Dev.to Laravel
  • Diterjemah daripada artikel asal ke Bahasa Melayu oleh AI Editorial – haqis.com
Comments

Similar Posts