Mengapa Differential Testing Diperlukan pada Query Engine SQL

Membangun query engine yang kompatibel dengan PostgreSQL membutuhkan verifikasi ratusan aturan semantik: prioritas operator, implicit type casting, penanganan NULL pada operasi boolean tri-state, hingga perilaku fungsi agregat. Verifikasi manual atau pengujian berbasis unit test konvensional rentan melewatkan kasus batas (edge cases).

Differential testing menyelesaikan masalah ini dengan memperlakukan sistem referensi (PostgreSQL upstream) dan sistem yang diuji (query engine berbasis Rust) sebagai black box. Keduanya menerima input SQL yang identik, kemudian harness pengujian membandingkan respons eksekusi. Perbedaan output langsung menandai adanya deviasi spesifikasi atau regresi implementasi.

Menangani Output Non-Deterministik

Membandingkan output teks mentah secara langsung akan memicu banyak false positive. Query engine SQL menghasilkan data yang secara fungsional setara namun representasinya berbeda. Normalisasi wajib diterapkan pada level harness pengujian.

1. Urutan Record Tanpa ORDER BY

Relational algebra mendefinisikan tabel sebagai multiset (bag) tanpa urutan intrinsik. Query tanpa klausul ORDER BY dapat menghasilkan urutan tuple yang berbeda tergantung rencana eksekusi (misalnya parallel scan vs index scan). Solusinya: harness harus mendeteksi ketiadaan klausul ORDER BY via parser SQL (seperti sqlparser-rs) atau mengurutkan seluruh baris hasil secara leksikografis di memori sebelum asersi.

2. Representasi Presisi Floating Point

Perbedaan konversi floating point IEEE 754 dan string formatter antara PostgreSQL (berbasis C) dan Rust engine sering memunculkan perbedaan representasi trailing zero (contoh: 0.1 vs 0.1000) atau rounding artifact. Normalisasikan nilai numerik bertipe f32 atau f64 ke format string dengan batas toleransi epsilon tertentu (misal: format!("{:.6}", val)).

3. Error String vs SQLSTATE

Pesan error manusiawi hampir pasti berbeda antar-engine. PostgreSQL menghasilkan string seperti column "x" does not exist. Engine Rust Anda mungkin menghasilkan unknown column: x. Asersi kegagalan query tidak boleh menguji teks error, melainkan harus mencocokkan kode SQLSTATE standar (contoh: 42703 untuk UNDEFINED COLUMN atau 42P01 untuk UNDEFINED TABLE).

Harness Pengujian Konkuren di Rust

Berikut implementasi minimal harness differential testing yang mengeksekusi query secara bersamaan ke Postgres upstream (melalui tokio-postgres) dan engine lokal, menormalisasi baris, serta melakukan diff.

use std::cmp::Ordering;
use tokio_postgres::{Client, NoTls, Row};

#[derive(Debug, PartialEq, Eq)]
pub enum ExecutionResult {
    Success(Vec<Vec<String>>),
    Error(String), // Berisi SQLSTATE 5 karakter
}

// ponytail: normalisasi float disederhanakan; tingkatkan ke parsing epsilon jika engine presisi tinggi.
fn normalize_cell(raw: Option<&str>) -> String {
    match raw {
        None => "NULL".to_string(),
        Some(val) => {
            if let Ok(f) = val.parse::<f64>() {
                format!("{:.6}", f)
            } else {
                val.trim().to_string()
            }
        }
    }
}

fn normalize_rows(mut rows: Vec<Vec<String>>, requires_sort: bool) -> Vec<Vec<String>> {
    if requires_sort {
        rows.sort_by(|a, b| {
            for (ca, cb) in a.iter().zip(b.iter()) {
                let cmp = ca.cmp(cb);
                if cmp != Ordering::Equal {
                    return cmp;
                }
            }
            a.len().cmp(&b.len())
        });
    }
    rows
}

pub trait LocalEngine: Send + Sync {
    fn execute(&self, sql: &str) -> Result<Vec<Vec<String>>, String>;
}

pub async fn run_diff_test<E: LocalEngine>(
    sql: &str,
    pg_client: &Client,
    engine: &E,
    has_order_by: bool,
) {
    let pg_future = async {
        match pg_client.query(sql, &[]).await {
            Ok(rows) => {
                let mapped: Vec<Vec<String>> = rows
                    .iter()
                    .map(|r| {
                        (0..r.len())
                            // Ekstraksi representasi string generik per kolom
                            .map(|i| normalize_cell(r.get::<usize, Option<&str>>(i)))
                            .collect()
                    })
                    .collect();
                ExecutionResult::Success(normalize_rows(mapped, !has_order_by))
            }
            Err(e) => ExecutionResult::Error(
                e.code().map(|c| c.code().to_string()).unwrap_or_else(|| "UNKNOWN".into()),
            ),
        }
    };

    let engine_future = async {
        match engine.execute(sql) {
            Ok(rows) => {
                let mapped = rows
                    .into_iter()
                    .map(|row| row.into_iter().map(|c| normalize_cell(Some(&c))).collect())
                    .collect();
                ExecutionResult::Success(normalize_rows(mapped, !has_order_by))
            }
            Err(sqlstate) => ExecutionResult::Error(sqlstate),
        }
    };

    let (pg_res, engine_res) = tokio::join!(pg_future, engine_future);
    assert_eq!(
        pg_res, engine_res,
        "Differential mismatch pada query:\n{}",
        sql
    );
}

Dilewati: koneksi pool dinamis dan dynamic type reflection. Tambahkan saat pengujian mencakup tipe data biner atau array kompleks.

Eliminasi Flaky Test Akibat State Drift

Testing query mutasi (INSERT, UPDATE, DELETE, CREATE TABLE) dapat menimbulkan efek samping persisten. Jika query berikutnya mengasumsikan database dalam keadaan bersih, pengujian akan bersifat flaky.

  • Isolated Database per Test Runner: Manfaatkan template database Postgres. Jalankan CREATE DATABASE test_db TEMPLATE template0 sebelum setiap batch test dijalankan secara paralel. Hindari penggunaan satu database bersama antar-thread pengujian.
  • Transaction Rollback: Bungkus setiap statement uji dalam blok transaksi Postgres (BEGIN ... ROLLBACK). Pastikan query engine lokal Anda mendukung level isolasi transaksi yang setara.
  • Container Ephemeral via Testcontainers: Di Rust, gunakan crate testcontainers untuk menjalankan instans container Postgres via API Docker lokal. Setiap test process memiliki container independen yang dihentikan otomatis saat struct di-drop.

Integrasi sqllogictest-rs dan Pipeline CI

Alih-alih menulis file query manual dari nol, gunakan sqllogictest-rs. Tool ini adalah parser dan runner standar format pengujian SQLite/DuckDB/PostgreSQL yang membaca file skrip berekstensi .test.

Harness Anda cukup mengimplementasikan trait sqllogictest::AsyncDB yang meneruskan query ke engine Rust Anda dan Postgres upstream secara bergantian. Di GitHub Actions, jalankan pengujian menggunakan service container resmi:

name: Differential Compatibility
on: [push, pull_request]

jobs:
  diff-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: postgrespassword
          POSTGRES_DB: diff_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 2s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Run Differential Tests
        run: cargo test --test differential -- --nocapture
        env:
          PG_URL: postgres://postgres:postgrespassword@localhost:5432/diff_test

Pendekatan ini memverifikasi bahwa setiap perubahan pada kode planner atau execution engine di Rust langsung tervalidasi terhadap perilaku standar PostgreSQL, menghentikan regresi sebelum masuk ke branch utama.