sqlpostgresqlrustrust-sqlx

Properly dealing with hierarchies in Rust & SQLx


I am working on a REST API written in Rust using actix-web, SQLx and PostgreSQL for storage. Let's suppose this is my schema (expressed as Rust structs):

struct User {
    pub id: Uuid,
    pub email: String
    // And so on...
}

struct Customer {
    pub id: Uuid,
    pub user_id: Uuid,
    pub name: String,
    // And so on...
}

My current goal is to implement an endpoint, that returns all users with their customers nested. I.e. like this:

// GET /users
// Response from endpoint
[{
  "id": "uuid-1",
  "email": "test@test.com",
  "customers": [{
    "id": "uuid-customer-1",
    "name": "Customer 1"
  }, {
    "id": "uuid-customer-2",
    "name": "Customer 2"
  }]
}]

The payload above could be expressed using the following structs:

#[derive(Serialize)]
struct CustomerData {
    pub id: Uuid,
    pub name: String
}

#[derive(Serialize)]
struct UserData {
    pub id: Uuid,
    pub email: String,
    pub customers: Vec<CustomerData>
}

Using SQLx macro query_as! I came up with following solution attempt:


let result = sqlx::query_as!(
    UserData,
    r#"
    SELECT U.id, U.email, array_agg((C.id, C.name)) as "customers" FROM users U 
    INNER JOIN customers C ON user_id = U.id
    GROUP BY U.id
    "#
)
    .fetch_all(pool.as_ref())
    .await?;

This, however fails, because the result returned by array_agg is of type RECORD[], which SQLx apparently does not yet support.

This issue got me wondering:


Solution

  • Explanation ahead; TL;DR at the bottom.

    After more digging, I found a solution, that is actually quite obvious once you get used to SQLx and Rust.

    So, the issue is, that SQLx considers the return value of ARRAY_AGG() to be of type RECORD[]. Luckily, SQLX allows us to tell which type we expect by type casting in their DSL.

    So, to solve the issue, we first need to cast our RECORD[] to Vec<CustomerData> in the query, which can be done like this:

        SELECT 
            id, 
            email, 
            ARRAY_AGG((C.id, C.name)) as "customers: Vec<CustomerData>" 
        FROM users 
        JOIN customers C ON user_id = U.id
        GROUP BY id, email
    

    Additionally, we need to implement the Trait sqlx::Type for CustomerData. Luckily there's a macro for that:

    #[derive(sqlx::Type, Serialize)]
    struct CustomerData {
        // ...
    }
    

    Last but not least, there is a final issue to resolve: ARRAY_AGG either returns NULL or an array. This can be solved in three ways:

        SELECT 
            id, 
            email, 
            COALESCE(NULLIF(ARRAY_AGG((C.id, C.name)), '{NULL}'), '{}') as "customers: Vec<CustomerData>" 
        FROM users 
        JOIN customers C ON user_id = U.id
        GROUP BY id, email
    

    This solution works, if hierarchies depth is no more than 1. You'll hit a wall due to a bug in the SQLx' type resolver, if you nest deeper. The Issue regarding this can be found here.


    TL;DR:

    SELECT
      ARRAY_AGG(JOINED.id) as "field_name!: Vec<AggregateType>"
    FROM ...
    

    EDIT: To my dismay, custom enums additionally count as a level in the hierachy. Thus, if your nested type has an enum, this is not supported. Even for the current version 0.7.1.