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.