Parameter Query SQLAlchemy yang Nggak Mau Ekstra
Filter locale opsional bikin live search zonk di locale default. Pelajaran soal text() SQLAlchemy, bindparams, dan klausa yang harus kompak.
Search blog jadi zonk total di locale default. Bukan timeout, bukan error 500. Hasil pencariannya kosong mentah-mentah. Barusan saya pasang filter locale opsional di endpoint search, lengkap dengan klausa `AND locale = :locale` di SQL text()-nya, sekalian masukin parameternya juga. Hitung-hitungan sekalian. Ternyata itu yang bikin semua hancur.
Endpoint pencarian di adityo.web.id pakai MariaDB FULLTEXT index, `MATCH(title, excerpt, content) AGAINST(:terms IN NATURAL LANGUAGE MODE)`, dengan fallback ke LIKE bila FULLTEXT nggak cocok. Kedua jalur ini pakai `text()` SQLAlchemy, karena query-nya kompleks: klausa MATCH berbeda tiap mode, ORDER BY score DESC, LIMIT dari parameter. Sebelum fitur locale, semua parameter wajib: `status`, `terms`, `limit`. Semua dikirim di `bindparams()` tanpa syarat. Nggak pernah masalah.
Masalah muncul waktu saya tambahin filter locale sebagai opsional. Artinya endpoint ini harus bisa dipakai dengan atau tanpa filter bahasa: kadang pencarian lintas locale, kadang hanya untuk `id`, kadang hanya `en`. Logika natural: kalo user nggak kirim locale, jangan pakai filter. Kalo kirim, tambahin `AND locale = :locale` ke SQL-nya.
Saya cek kodenya, nampak benar. Klausa `AND locale = :locale` cuma muncul kalo `locale` dikirim. Parameter `locale` juga cuma dikirim kalo ada. Tapi di mana saya salah: saya selalu masukin `locale` ke `bindparams()` dulu, meski nanti nggak dipakai di SQL. Beda tipis, tapi fatal.
`text()` Nolak Parameter yang Nggak Terdefinisi
Saya test langsung di venv repo, SQLAlchemy versi 2.0.51. Coba simplest case: `text("SELECT x FROM t LIMIT :limit")` lalu `.bindparams(limit=5, locale="id")`.
Langsung lempar `ArgumentError`: This text() construct doesn't define a bound parameter named 'locale'.
Ini bukan bug, ini fitur [2]. SQLAlchemy nge-validasi bind params pas konstruksi `text()` object, bukan pas query dieksekusi ke driver [1]. Safety mechanism: biar nggak ada parameter yang ngirim value tapi nggak ke-handle di SQL-nya. Bila kamu kirim parameter yang nggak ada di string SQL, dia langsung tolak di layer Python, sebelum query sampai ke MariaDB.
Yang terjadi di search endpoint: saya selalu masukin `locale` ke bindparams, tapi cuma nulis klausa `AND locale = :locale` pas filter locale-nya ada. Waktu locale nggak dikirim, parameter `locale` tetap dikirim tapi nggak ada yang manggil di SQL-nya. SQLAlchemy nolak sebelum query jalan. Error-nya deskriptif, tapi karena saya cek kodenya via code review dan nggak langsung test path tanpa locale, saya sempat bingung: "kan klausa-nya juga nggak ada?".
Fix: bangun dari satu sumber kebenaran
Solusinya simpel. Di `_search_fts` dan `_search_like`, saya bangun dict params dulu, masukin key `locale` cuma kalo filter-nya ada, tambahin klausa AND-nya juga cuma kalo key-nya ada, baru `bindparams(**params)`.
params: dict[str, Any] = {
"status": ArticleStatus.PUBLISHED.value,
"terms": " ".join(terms),
"limit": limit,
}
if locale:
params["locale"] = locale
# Klausa SQL juga hanya muncul bila locale ada
text(
f"SELECT slug, title, excerpt, published_at, {match} AS score "
f"FROM articles WHERE status = :status "
f"{'AND locale = :locale ' if locale else ''}"
f"AND {match} ORDER BY score DESC LIMIT :limit"
).bindparams(**params)
Klausa dan parameter dibangun bareng dari dict yang sama. Nggak ada yang kirim tapi nggak dipakai, nggak ada yang dipakai tapi nggak dikirim. Dualisme itu yang bikin zonk tadi.
Polanya mirip dengan konsep `expanding` IN parameter di SQLAlchemy [2], yang memecah parameter list ke beberapa slot secara dinamis. Bedanya: expanding dikerjakan oleh SQLAlchemy compiler, sementara ini versi manual: kita yang kontrol kapan key masuk dict. Kalo nggak butuh kontrol se-detail ini, SQLAlchemy punya pola komposisi `WHERE` dari ekspresi Python lewat `.where()` [3], jadi nggak pernah nyentuh SQL mentah. Lebih aman, nggak gampang kejebak.
Tapi untuk fulltext search dengan fallback LIKE, yang butuh kontrol penuh atas bentuk string SQL, MATCH syntax, dan scoring, `text()` masih jadi pilihan yang tepat. Saya pribadi lebih suka pakai ORM `.where()` kalo endpoint-nya cuma filter-filter biasa. Tapi kalo fulltext MariaDB yang jadi kebutuhan, harus berdamai dengan raw SQL, dan artinya harus jaga konsistensi antara parameter dan klausa.
Sumber
- [1] SQLAlchemy: Working with Engines and Connections
- [2] SQLAlchemy: Column Elements and Expressions (text, bindparams)
- [3] SQLAlchemy ORM Query Guide: SELECT
Konteks artikel lain yang nyambung: jalur FULLTEXT MariaDB plus ekspansi query LLM, vector store Python tanpa numpy, dan tipe TypeScript yang nggak ikut sampai ke JSON.