ExamplesBy LevelBy TopicLearning Paths
181 Advanced

Type-Safe SQL-like Query Builder

Functional Programming

Tutorial

The Problem

A SQL query builder that allows calling .where_() before .from(), or calling .build() without a SELECT clause, is a footgun — the error appears at runtime when the query fails, not at the API call site. Type-safe builders use typestate to enforce calling order at compile time: build() is only available when all required clauses have been provided. This pattern appears in HTTP clients (reqwest), ORMs (diesel), and configuration APIs.

🎯 Learning Outcomes

  • • Apply typestate to a builder pattern enforcing required steps in a specific order
  • • Use multiple phantom type parameters (S, F, W) to track independent requirements
  • • See how consuming self in each builder method enforces a linear construction flow
  • • Understand the practical impact: "call build without FROM" is a compile error, not a runtime error
  • Code Example

    impl<F, W> Query<NoSelect, F, W> {
        fn select(self, cols: &str) -> Query<HasSelect, F, W> { /* ... */ }
    }
    impl<W> Query<HasSelect, NoFrom, W> {
        fn from(self, table: &str) -> Query<HasSelect, HasFrom, W> { /* ... */ }
    }
    impl Query<HasSelect, HasFrom, NoWhere> {
        fn where_(self, cond: &str) -> Query<HasSelect, HasFrom, HasWhere> { /* ... */ }
    }
    
    // Usage
    let sql = Query::new().select("*").from("users").where_("age > 18").build();

    Key Differences

  • Multiple state dimensions: Multiple phantom parameters independently track each required clause — orthogonal state dimensions; OCaml achieves this similarly.
  • Move on step: Rust consumes the builder at each step (preventing accidental use of intermediate states); OCaml retains old values.
  • Optional clauses: The W phantom allows build() with or without a WHERE clause — optional requirements are captured by accepting any W.
  • Error messages: Rust's compile error "method build not found" is clear; adding #[doc] to the phantom-gated method improves discoverability.
  • OCaml Approach

    OCaml's phantom type approach:

    type ('s, 'f) query = { select_: string option; from_: string option }
    let select q s = { q with select_ = Some s }
    let from q t = { q with from_ = Some t }
    let build : (has_select, has_from) query -> string = fun q -> ...
    

    State transitions via phantom types work similarly in OCaml, but without move semantics — the old query value remains accessible after each builder step. Rust's consuming transitions are stricter.

    Full Source

    #![allow(clippy::all)]
    // Example 181: Type-Safe SQL-like Query Builder
    // Enforce SELECT before WHERE at compile time using phantom types
    
    use std::marker::PhantomData;
    
    // === Approach 1: Type-state builder ===
    
    struct NoSelect;
    struct HasSelect;
    struct NoFrom;
    struct HasFrom;
    struct NoWhere;
    struct HasWhere;
    
    struct Query<S, F, W> {
        select: Option<String>,
        from: Option<String>,
        where_: Option<String>,
        order_by: Option<String>,
        _s: PhantomData<(S, F, W)>,
    }
    
    impl Query<NoSelect, NoFrom, NoWhere> {
        fn new() -> Self {
            Query {
                select: None,
                from: None,
                where_: None,
                order_by: None,
                _s: PhantomData,
            }
        }
    }
    
    impl<F, W> Query<NoSelect, F, W> {
        fn select(self, cols: &str) -> Query<HasSelect, F, W> {
            Query {
                select: Some(cols.to_string()),
                from: self.from,
                where_: self.where_,
                order_by: self.order_by,
                _s: PhantomData,
            }
        }
    }
    
    impl<W> Query<HasSelect, NoFrom, W> {
        fn from(self, table: &str) -> Query<HasSelect, HasFrom, W> {
            Query {
                select: self.select,
                from: Some(table.to_string()),
                where_: self.where_,
                order_by: self.order_by,
                _s: PhantomData,
            }
        }
    }
    
    impl Query<HasSelect, HasFrom, NoWhere> {
        fn where_(self, cond: &str) -> Query<HasSelect, HasFrom, HasWhere> {
            Query {
                select: self.select,
                from: self.from,
                where_: Some(cond.to_string()),
                order_by: self.order_by,
                _s: PhantomData,
            }
        }
    }
    
    impl<W> Query<HasSelect, HasFrom, W> {
        fn order_by(mut self, col: &str) -> Self {
            self.order_by = Some(col.to_string());
            self
        }
    
        fn build(&self) -> String {
            let mut sql = format!(
                "SELECT {} FROM {}",
                self.select.as_ref().unwrap(),
                self.from.as_ref().unwrap()
            );
            if let Some(w) = &self.where_ {
                sql.push_str(&format!(" WHERE {}", w));
            }
            if let Some(o) = &self.order_by {
                sql.push_str(&format!(" ORDER BY {}", o));
            }
            sql
        }
    }
    
    // === Approach 2: Trait-based builder with associated types ===
    
    trait BuilderState {}
    trait CanAddFrom: BuilderState {}
    trait CanAddWhere: BuilderState {}
    trait CanBuild: BuilderState {}
    
    struct Selected;
    struct FromAdded;
    struct WhereAdded;
    
    impl BuilderState for Selected {}
    impl BuilderState for FromAdded {}
    impl BuilderState for WhereAdded {}
    impl CanAddFrom for Selected {}
    impl CanAddWhere for FromAdded {}
    impl CanBuild for FromAdded {}
    impl CanBuild for WhereAdded {}
    
    struct QueryBuilder<S: BuilderState> {
        parts: Vec<String>,
        _state: PhantomData<S>,
    }
    
    impl QueryBuilder<Selected> {
        fn select(cols: &str) -> Self {
            QueryBuilder {
                parts: vec![format!("SELECT {}", cols)],
                _state: PhantomData,
            }
        }
    }
    
    impl<S: CanAddFrom> QueryBuilder<S> {
        fn from(mut self, table: &str) -> QueryBuilder<FromAdded> {
            self.parts.push(format!("FROM {}", table));
            QueryBuilder {
                parts: self.parts,
                _state: PhantomData,
            }
        }
    }
    
    impl<S: CanAddWhere> QueryBuilder<S> {
        fn where_clause(mut self, cond: &str) -> QueryBuilder<WhereAdded> {
            self.parts.push(format!("WHERE {}", cond));
            QueryBuilder {
                parts: self.parts,
                _state: PhantomData,
            }
        }
    }
    
    impl<S: CanBuild> QueryBuilder<S> {
        fn build(&self) -> String {
            self.parts.join(" ")
        }
    }
    
    // === Approach 3: Runtime builder for comparison ===
    
    #[derive(Default)]
    struct FluentQuery {
        select: Option<String>,
        from: Option<String>,
        where_: Option<String>,
    }
    
    impl FluentQuery {
        fn select(mut self, cols: &str) -> Self {
            self.select = Some(cols.into());
            self
        }
        fn from(mut self, table: &str) -> Self {
            self.from = Some(table.into());
            self
        }
        fn where_(mut self, cond: &str) -> Self {
            self.where_ = Some(cond.into());
            self
        }
    
        fn build(&self) -> Result<String, &'static str> {
            match (&self.select, &self.from) {
                (Some(s), Some(f)) => {
                    let mut sql = format!("SELECT {} FROM {}", s, f);
                    if let Some(w) = &self.where_ {
                        sql.push_str(&format!(" WHERE {}", w));
                    }
                    Ok(sql)
                }
                _ => Err("SELECT and FROM are required"),
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_type_state_basic() {
            let sql = Query::new().select("*").from("users").build();
            assert_eq!(sql, "SELECT * FROM users");
        }
    
        #[test]
        fn test_type_state_where() {
            let sql = Query::new()
                .select("name")
                .from("users")
                .where_("age > 18")
                .build();
            assert_eq!(sql, "SELECT name FROM users WHERE age > 18");
        }
    
        #[test]
        fn test_type_state_order() {
            let sql = Query::new()
                .select("*")
                .from("users")
                .order_by("name")
                .build();
            assert_eq!(sql, "SELECT * FROM users ORDER BY name");
        }
    
        #[test]
        fn test_trait_builder() {
            let sql = QueryBuilder::select("*").from("t").build();
            assert_eq!(sql, "SELECT * FROM t");
        }
    
        #[test]
        fn test_trait_builder_where() {
            let sql = QueryBuilder::select("a")
                .from("b")
                .where_clause("c=1")
                .build();
            assert_eq!(sql, "SELECT a FROM b WHERE c=1");
        }
    
        #[test]
        fn test_fluent_ok() {
            let r = FluentQuery::default().select("*").from("t").build();
            assert!(r.is_ok());
        }
    
        #[test]
        fn test_fluent_missing() {
            let r = FluentQuery::default().build();
            assert!(r.is_err());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_type_state_basic() {
            let sql = Query::new().select("*").from("users").build();
            assert_eq!(sql, "SELECT * FROM users");
        }
    
        #[test]
        fn test_type_state_where() {
            let sql = Query::new()
                .select("name")
                .from("users")
                .where_("age > 18")
                .build();
            assert_eq!(sql, "SELECT name FROM users WHERE age > 18");
        }
    
        #[test]
        fn test_type_state_order() {
            let sql = Query::new()
                .select("*")
                .from("users")
                .order_by("name")
                .build();
            assert_eq!(sql, "SELECT * FROM users ORDER BY name");
        }
    
        #[test]
        fn test_trait_builder() {
            let sql = QueryBuilder::select("*").from("t").build();
            assert_eq!(sql, "SELECT * FROM t");
        }
    
        #[test]
        fn test_trait_builder_where() {
            let sql = QueryBuilder::select("a")
                .from("b")
                .where_clause("c=1")
                .build();
            assert_eq!(sql, "SELECT a FROM b WHERE c=1");
        }
    
        #[test]
        fn test_fluent_ok() {
            let r = FluentQuery::default().select("*").from("t").build();
            assert!(r.is_ok());
        }
    
        #[test]
        fn test_fluent_missing() {
            let r = FluentQuery::default().build();
            assert!(r.is_err());
        }
    }

    Deep Comparison

    Comparison: Example 181 — Type-Safe Query Builder

    State-Tracked Builder

    OCaml

    let select cols (q : (empty_q, 'f, 'w) query) : (has_select, 'f, 'w) query =
      { q with select_clause = Some cols }
    
    let from table (q : (has_select, empty_q, 'w) query) : (has_select, has_from, 'w) query =
      { q with from_clause = Some table }
    
    let where_ cond (q : (has_select, has_from, empty_q) query) =
      { q with where_clause = Some cond }
    
    (* Usage *)
    let sql = empty_query |> select "*" |> from "users" |> where_ "age > 18"
    

    Rust

    impl<F, W> Query<NoSelect, F, W> {
        fn select(self, cols: &str) -> Query<HasSelect, F, W> { /* ... */ }
    }
    impl<W> Query<HasSelect, NoFrom, W> {
        fn from(self, table: &str) -> Query<HasSelect, HasFrom, W> { /* ... */ }
    }
    impl Query<HasSelect, HasFrom, NoWhere> {
        fn where_(self, cond: &str) -> Query<HasSelect, HasFrom, HasWhere> { /* ... */ }
    }
    
    // Usage
    let sql = Query::new().select("*").from("users").where_("age > 18").build();
    

    Compile-Time Error

    OCaml

    (* Won't compile: from needs has_select *)
    let _ = empty_query |> from "users"
    (* Error: This expression has type (empty_q, empty_q, empty_q) query
       but expected (has_select, empty_q, 'w) query *)
    

    Rust

    // Won't compile: no from() on NoSelect
    Query::new().from("users");
    // Error: no method named `from` found for `Query<NoSelect, NoFrom, NoWhere>`
    

    Exercises

  • Add an order_by clause as an optional phantom state HasOrderBy | NoOrderBy.
  • Implement limit(self, n: u32) -> Query<S, F, W> (doesn't change type state — always optional).
  • Add a validation step in build() that checks for SQL injection patterns in string fields.
  • Open Source Repos