Why a typed query builder?

A query builder is useful when your application needs dynamic SQL without sacrificing readability or safety. Common examples include:

  • optional filters based on user input
  • reusable pagination logic
  • consistent parameter binding
  • avoiding string concatenation bugs
  • preventing accidental SQL injection from interpolated values

A typed builder improves on a plain string builder by separating:

  • SQL structure: table, selected columns, filters, ordering
  • bound values: parameters passed separately to the database driver

That separation is important because it encourages safe parameterization and makes the final SQL easier to inspect and test.


Design goals

We will build a builder with these properties:

  • supports SELECT, FROM, WHERE, ORDER BY, LIMIT, and OFFSET
  • stores parameters separately from SQL text
  • uses Rust types to represent query parts
  • remains lightweight and easy to extend
  • produces a final SQL string plus a list of bound values

For simplicity, we will target a generic SQL dialect with positional placeholders like ?. That keeps the example focused on the builder pattern rather than database-specific syntax.


Core data model

We need a structure to hold the query state as it is assembled. A practical approach is to store:

  • selected columns as Vec<String>
  • table name as String
  • conditions as SQL fragments
  • parameters as a list of values
  • optional sort and pagination settings

Here is a minimal implementation:

#[derive(Debug, Clone)]
pub enum Value {
    Int(i64),
    Text(String),
    Bool(bool),
}

#[derive(Debug, Clone)]
pub struct Condition {
    sql: String,
    params: Vec<Value>,
}

#[derive(Debug, Default)]
pub struct SelectQuery {
    table: Option<String>,
    columns: Vec<String>,
    conditions: Vec<Condition>,
    order_by: Option<String>,
    limit: Option<u32>,
    offset: Option<u32>,
}

impl SelectQuery {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn from(mut self, table: impl Into<String>) -> Self {
        self.table = Some(table.into());
        self
    }

    pub fn select(mut self, columns: &[&str]) -> Self {
        self.columns = columns.iter().map(|c| c.to_string()).collect();
        self
    }

    pub fn where_eq(mut self, column: impl Into<String>, value: Value) -> Self {
        let sql = format!("{} = ?", column.into());
        self.conditions.push(Condition {
            sql,
            params: vec![value],
        });
        self
    }

    pub fn where_like(mut self, column: impl Into<String>, pattern: impl Into<String>) -> Self {
        let sql = format!("{} LIKE ?", column.into());
        self.conditions.push(Condition {
            sql,
            params: vec![Value::Text(pattern.into())],
        });
        self
    }

    pub fn order_by(mut self, clause: impl Into<String>) -> Self {
        self.order_by = Some(clause.into());
        self
    }

    pub fn limit(mut self, n: u32) -> Self {
        self.limit = Some(n);
        self
    }

    pub fn offset(mut self, n: u32) -> Self {
        self.offset = Some(n);
        self
    }
}

This version is intentionally simple. It already gives us a clean API, but we can improve it further by adding a build() method that generates SQL and collects parameters.


Building the final SQL

The build() method should return both the SQL string and the parameter list. That makes it easy to pass the result to a database driver such as sqlx, rusqlite, or postgres.

impl SelectQuery {
    pub fn build(self) -> Result<(String, Vec<Value>), String> {
        let table = self.table.ok_or("missing table name")?;

        let select_part = if self.columns.is_empty() {
            "*".to_string()
        } else {
            self.columns.join(", ")
        };

        let mut sql = format!("SELECT {} FROM {}", select_part, table);
        let mut params = Vec::new();

        if !self.conditions.is_empty() {
            sql.push_str(" WHERE ");

            let mut parts = Vec::new();
            for condition in self.conditions {
                parts.push(condition.sql);
                params.extend(condition.params);
            }

            sql.push_str(&parts.join(" AND "));
        }

        if let Some(order_by) = self.order_by {
            sql.push_str(" ORDER BY ");
            sql.push_str(&order_by);
        }

        if let Some(limit) = self.limit {
            sql.push_str(" LIMIT ?");
            params.push(Value::Int(limit as i64));
        }

        if let Some(offset) = self.offset {
            sql.push_str(" OFFSET ?");
            params.push(Value::Int(offset as i64));
        }

        Ok((sql, params))
    }
}

This method does three important things:

  1. validates required state, such as the table name
  2. assembles SQL in a predictable order
  3. keeps bound values separate from the SQL text

That separation is the foundation of safe query execution.


Using the builder in practice

Let’s build a query for active users whose names match a pattern, ordered by creation date, with pagination.

fn main() {
    let query = SelectQuery::new()
        .select(&["id", "name", "email"])
        .from("users")
        .where_eq("active", Value::Bool(true))
        .where_like("name", "A%")
        .order_by("created_at DESC")
        .limit(20)
        .offset(40);

    let (sql, params) = query.build().unwrap();

    println!("SQL: {}", sql);
    println!("Params: {:?}", params);
}

The output would look like this:

SQL: SELECT id, name, email FROM users WHERE active = ? AND name LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ?
Params: [Bool(true), Text("A%"), Int(20), Int(40)]

This is already useful in a real application because the query structure is readable and the parameters are explicit.


Why separate SQL fragments from values?

A common mistake is to build a query like this:

let sql = format!("SELECT * FROM users WHERE name = '{}'", name);

That approach is fragile and unsafe. It can break on quotes, special characters, and malicious input. A typed builder should never embed user data directly into SQL text.

Instead, the builder should produce:

  • SQL with placeholders
  • a parameter list for the database driver

This design works well with prepared statements and reduces the risk of injection vulnerabilities.


Improving type safety with generic column markers

The previous implementation is practical, but it still accepts arbitrary strings for table and column names. In many applications, you can do better by modeling known columns as Rust types.

For example, suppose you have a User table with a fixed set of columns. You can define a column enum:

#[derive(Debug, Clone, Copy)]
pub enum UserColumn {
    Id,
    Name,
    Email,
    Active,
    CreatedAt,
}

impl UserColumn {
    pub fn as_str(self) -> &'static str {
        match self {
            UserColumn::Id => "id",
            UserColumn::Name => "name",
            UserColumn::Email => "email",
            UserColumn::Active => "active",
            UserColumn::CreatedAt => "created_at",
        }
    }
}

Then you can adapt the builder to accept UserColumn instead of raw strings for selected columns and conditions. This reduces typos and makes refactoring safer.

A comparison helps clarify the tradeoff:

ApproachProsCons
Raw stringsFlexible, quick to writeEasy to mistype, harder to refactor
Typed column enumSafer, self-documentingRequires upfront modeling
Full ORMHigh-level abstraction, rich featuresMore complexity, less control

For many codebases, the typed enum approach is a sweet spot.


Adding typed filters

We can go one step further and encode common filters as methods that accept specific Rust types. This improves ergonomics and keeps query construction consistent.

impl SelectQuery {
    pub fn where_bool(mut self, column: impl Into<String>, value: bool) -> Self {
        let sql = format!("{} = ?", column.into());
        self.conditions.push(Condition {
            sql,
            params: vec![Value::Bool(value)],
        });
        self
    }

    pub fn where_int(mut self, column: impl Into<String>, value: i64) -> Self {
        let sql = format!("{} = ?", column.into());
        self.conditions.push(Condition {
            sql,
            params: vec![Value::Int(value)],
        });
        self
    }
}

Now the call site is clearer:

let query = SelectQuery::new()
    .select(&["id", "name"])
    .from("users")
    .where_bool("active", true)
    .where_int("id", 42);

This pattern is especially useful when your application frequently filters by common field types.


Handling optional filters cleanly

Real-world queries often depend on user input. A search page might allow filtering by name, status, and minimum age, but only some filters are present at runtime.

A typed builder makes this easy:

fn build_user_search(
    name: Option<&str>,
    active: Option<bool>,
    min_id: Option<i64>,
) -> Result<(String, Vec<Value>), String> {
    let mut query = SelectQuery::new()
        .select(&["id", "name", "active"])
        .from("users");

    if let Some(name) = name {
        query = query.where_like("name", format!("{}%", name));
    }

    if let Some(active) = active {
        query = query.where_bool("active", active);
    }

    if let Some(min_id) = min_id {
        query = query.where_int("id", min_id);
    }

    query.build()
}

This style is readable and avoids nested string assembly logic. Each optional filter is added independently, which scales well as the number of search criteria grows.


Best practices for production use

A small builder like this can be a strong foundation, but production code should follow a few rules:

  • Validate identifiers: table and column names should come from trusted sources or typed enums.
  • Use prepared statements: pass the SQL and parameters to your database driver.
  • Keep dialect differences isolated: placeholder syntax and pagination rules vary across databases.
  • Prefer composable methods: small methods like where_eq and where_like are easier to test.
  • Test generated SQL: unit tests should verify both SQL text and parameter order.

You should also consider whether your project needs a builder at all. If queries are static and simple, raw SQL with prepared statements may be enough. The builder becomes valuable when query shape changes frequently.


Extending the design

Once the basic builder works, you can extend it in several directions:

  • JOIN support for multi-table queries
  • GROUP BY and aggregate expressions
  • IN (...) conditions with variable-length parameter lists
  • nested boolean expressions with parentheses
  • database-specific placeholder formats such as $1, $2
  • typed result mapping into structs

A particularly useful extension is support for grouped conditions. For example, you may want:

WHERE (status = ? OR status = ?) AND active = ?

That requires a more structured condition tree rather than a flat list of strings. Rust enums are a natural fit for representing that kind of expression tree.


Summary

A typed SQL query builder in Rust gives you a practical balance between flexibility and safety. You can still assemble dynamic queries, but you avoid the fragility of manual string concatenation and keep values separate from SQL text.

The key ideas are:

  • model query parts with Rust types
  • generate SQL and parameters together
  • use typed helpers for common filters
  • validate identifiers and keep user data out of SQL strings
  • extend gradually as your application needs grow

This approach works especially well in services that build many similar queries, such as admin dashboards, search endpoints, and reporting tools.

Learn more with useful resources