Search is the smallest feature in the store: one form, one query, one results page. Small features are where you take a machine’s measure, because nothing is hiding behind scale. This one also carries the best war story in the whole port. Checkpoint: lesson-06.

If you did lesson 4’s challenge, you already wrote most of today’s query — compare your search_products against this one as we go.

The scar tissue: #{} versus ${}

Every MyBatis veteran carries this one. In a mapper, #{keyword} binds a parameter — safe, always. ${keyword} splices the raw string into the SQL text — and it exists because sometimes you legitimately need to interpolate a column name or sort direction. The trap is that both look alike, both work in the demo, and the second one turns a search box into an injection vector. Two characters of difference, one security incident.

Here’s the ported query, and the reason this lesson can’t reproduce that bug:

pub async fn search_products(
    pool: &SqlitePool,
    keyword: &str,
) -> Result<Vec<Product>, sqlx::Error> {
    let pattern = format!("%{}%", keyword.to_lowercase());
    sqlx::query_as!(
        Product,
        r#"SELECT productid as "id: ProductId", category as "category_id: CategoryId",
                  name as "name!", descn as "description!"
           FROM product
           WHERE lower(name) LIKE ?1 OR lower(descn) LIKE ?1
           ORDER BY productid"#,
        pattern
    )
    .fetch_all(pool)
    .await
}

The SQL is a literal the macro reads at compile time; ?1 is the only door in, and everything that goes through a door is bound. There is no splice syntax to reach for, no unsafe twin that looks like the safe one. You can still build injectable SQL in Rust if you go out of your way — string concatenation into the non-macro APIs — but the default path and the easy path are the same path, and that’s the property ${} never had.

Note the format! happens to the pattern, not the SQL: we’re wrapping the keyword in %…% wildcards and lowercasing it, then binding the result. One honest limitation to know about: a user who types % or _ gets wildcard behavior instead of a literal match. That’s not injection — it can’t escape the LIKE — but it is sloppiness, and the challenge deals with it.

A query string becomes a struct

Serde enters the port here, doing for the query string what query_as! does for result rows — turning stringly data into a type before your code runs:

#[derive(serde::Deserialize)]
pub struct SearchParams {
    #[serde(default)]
    keyword: String,
}

pub async fn search(
    State(pool): State<SqlitePool>,
    Query(params): Query<SearchParams>,
) -> AppResult<Html<String>> {
    let keyword = params.keyword.trim();
    let products = if keyword.is_empty() {
        Vec::new()
    } else {
        db::catalog::search_products(&pool, keyword).await?
    };
    Ok(Html(SearchTemplate { keyword: keyword.to_string(), products }.render()?))
}

The Stripes original does this with ActionBean property binding and a @Validate annotation; the Rust version is a plain struct plus the Query extractor. One deliberate choice deserves attention: #[serde(default)]. Without it, a bare /search — no ?keyword= at all — fails deserialization and 400s. With it, the missing parameter means “empty string,” and the handler treats an empty search as a page state, not an error: you land on the search page, it invites you to type. Whether a missing parameter is an error or a default is a decision, and this attribute is where you write it down.

Three states, one template

search.html renders the whole story with the template conditionals from lesson 5: an invitation when the keyword is empty, a “nothing matches” notice when the search misses, and the results table — the same product-row pattern the category page uses — when it hits. The form itself goes in base.html’s header, method="get", so every page can search and every search is a bookmarkable URL. The original put its search box in the site-wide header too; some decisions survive twenty years because they were right.

Build it

File Action What goes in it
Cargo.toml generated cargo add serde --features derive
src/db/catalog.rs write search_products, shown above (or reconcile your lesson-4 challenge version), plus two tests: “fish” finds 4 case-insensitively, “zebra” finds none
src/web/catalog.rs write SearchParams, SearchTemplate, and the search handler shown above
src/web/mod.rs modify One new route: /search
templates/search.html write The three states — empty keyword, no matches, results table
templates/base.html modify The header form: action="/search", method="get", one input named keyword
.sqlx/ generated cargo sqlx prepare — new query, new cache entry

Done when: ?keyword=fish lists four products, ?keyword=zebra says nothing matches, a bare /search renders the invitation with HTTP 200, and cargo test shows 10 passing.

Answer key: lesson-05...lesson-06.

Challenge: take % literally

A search for % currently matches everything in the store. Make wildcard characters mean themselves: escape % and _ in the keyword before building the pattern, and tell LIKE about it with SQL’s ESCAPE clause. Add the test that proves searching 100% matches nothing instead of everything. Small feature, sharp edges — that’s search.

Next lesson is the biggest jump so far: state. The cart arrives, and with it sessions, POST forms, and money that finally learns to add.