Card 08/ 09

ExerciseDifficulty: Advanced4 min

Practice: Building an SQL Query Step by Step

SELECT is required; WHERE, ORDER BY and LIMIT are each optional.

A search screen that turns filters into an SQL query is a good test of the Builder pattern, and this exercise asks you to design one and then judge whether Python needs it. A query always has SELECT columns and a FROM table. It may have any number of WHERE conditions, joined by AND, and optionally an ORDER BY and a LIMIT. The screen adds a condition for each filter the user fills in.

  1. Design a QueryBuilder: name its steps, and say what build() must check.
  2. Write search_orders(status, min_total, newest_first), which adds a condition only for each filter that is set.
  3. Decide whether a function with keyword arguments would serve as well, and say why.
Show a solution
py
from typing import Optional


class QueryBuilder:
    def __init__(self) -> None:
        self._columns: list[str] = []
        self._table: Optional[str] = None
        self._conditions: list[str] = []
        self._order: Optional[str] = None
        self._limit: Optional[int] = None

    def select(self, *columns: str) -> "QueryBuilder":
        self._columns.extend(columns)
        return self

    def from_table(self, table: str) -> "QueryBuilder":
        self._table = table
        return self

    def where(self, condition: str) -> "QueryBuilder":
        self._conditions.append(condition)
        return self

    def order_by(self, column: str) -> "QueryBuilder":
        self._order = column
        return self

    def limit(self, count: int) -> "QueryBuilder":
        self._limit = count
        return self

    def build(self) -> str:
        if not self._columns or self._table is None:
            raise ValueError("a query needs SELECT columns and a FROM table")
        sql = f"SELECT {', '.join(self._columns)} FROM {self._table}"
        if self._conditions:
            sql += " WHERE " + " AND ".join(self._conditions)
        if self._order:
            sql += f" ORDER BY {self._order}"
        if self._limit is not None:
            sql += f" LIMIT {self._limit}"
        return sql


def search_orders(status: Optional[str], min_total: Optional[int], newest_first: bool) -> str:
    query = QueryBuilder().select("id", "total").from_table("orders")
    if status:
        query.where(f"status = '{status}'")
    if min_total is not None:
        query.where(f"total >= {min_total}")
    if newest_first:
        query.order_by("created_at DESC").limit(20)
    return query.build()


print(search_orders(None, None, False))
print(search_orders("shipped", 50, True))
try:
    QueryBuilder().where("total > 0").build()
except ValueError as error:
    print("refused:", error)
OutputRan with python:3.12-slim (Python 3.12.13)
SELECT id, total FROM orders
SELECT id, total FROM orders WHERE status = 'shipped' AND total >= 50 ORDER BY created_at DESC LIMIT 20
refused: a query needs SELECT columns and a FROM table
Python: each filter that is set adds one step, and `build()` refuses a query with no SELECT or FROM.
java
import java.util.ArrayList;
import java.util.List;

public class BdQuery {
    static String searchOrders(String status, Integer minTotal, boolean newestFirst) {
        QueryBuilder query = new QueryBuilder().select("id", "total").from("orders");
        if (status != null) query.where("status = '" + status + "'");
        if (minTotal != null) query.where("total >= " + minTotal);
        if (newestFirst) query.orderBy("created_at DESC").limit(20);
        return query.build();
    }

    public static void main(String[] args) {
        System.out.println(searchOrders(null, null, false));
        System.out.println(searchOrders("shipped", 50, true));
        try {
            new QueryBuilder().where("total > 0").build();
        } catch (IllegalStateException error) {
            System.out.println("refused: " + error.getMessage());
        }
    }
}

class QueryBuilder {
    private final List<String> columns = new ArrayList<>();
    private String table;
    private final List<String> conditions = new ArrayList<>();
    private String order;
    private Integer limit;

    QueryBuilder select(String... names) { columns.addAll(List.of(names)); return this; }
    QueryBuilder from(String name) { table = name; return this; }
    QueryBuilder where(String condition) { conditions.add(condition); return this; }
    QueryBuilder orderBy(String column) { order = column; return this; }
    QueryBuilder limit(int count) { limit = count; return this; }

    String build() {
        if (columns.isEmpty() || table == null) {
            throw new IllegalStateException("a query needs SELECT columns and a FROM table");
        }
        StringBuilder sql = new StringBuilder("SELECT " + String.join(", ", columns) + " FROM " + table);
        if (!conditions.isEmpty()) sql.append(" WHERE ").append(String.join(" AND ", conditions));
        if (order != null) sql.append(" ORDER BY ").append(order);
        if (limit != null) sql.append(" LIMIT ").append(limit);
        return sql.toString();
    }
}
OutputRan with eclipse-temurin:21-jdk (Java 21.0.12)
SELECT id, total FROM orders
SELECT id, total FROM orders WHERE status = 'shipped' AND total >= 50 ORDER BY created_at DESC LIMIT 20
refused: a query needs SELECT columns and a FROM table
Java: the same builder, with a varargs `select`.

The steps are select, from_table, where, order_by and limit, and build() checks that there are columns and a table. where appends rather than replaces, so calling it twice gives two conditions joined by AND.

A function with keyword arguments would serve for the ORDER BY and LIMIT, which are set once. It serves poorly for the conditions, which accumulate: the caller would have to collect them into a list first, which is the builder's job done by hand. Here the builder earns its class, because the query is assembled across statements.

The listing pastes values into the SQL text to keep the output readable. Real code passes them as parameters to the database driver, because a value pasted into SQL can change what the query does, which is how SQL injection happens.