sqlx icon indicating copy to clipboard operation
sqlx copied to clipboard

[query_as!] Incorrect nullability inferred on left join leading to a runtime exception

Open jetaggart opened this issue 3 years ago • 7 comments

Bug Description

When doing a left join, sqlx infers a column as non-nullable vs an option.

Minimal Reproduction


#[derive(Clone)]
pub struct PlantWithUserDetails {
    pub id: Uuid,
    pub in_garden_at: Option<DateTime<Utc>>,
}

// ...
        let res  = sqlx::query_as!(PlantWithUserDetails, r#"
            SELECT plants.*, up.created_at as in_garden_at
            FROM plants
            LEFT JOIN user_plants up on plants.id = up.plant_id and up.user_id = $1
            WHERE plants.id = $2
        "#, id, user_id)
            .fetch_one(&self.db)
            .await?;

Leads to:

error[E0308]: mismatched types
  --> src/plant/plant_repo.rs:62:20
   |
62 |           let res  = sqlx::query_as!(PlantWithUserDetails, r#"
   |  ____________________^
63 | |             SELECT plants.*, up.created_at as in_garden_at
64 | |             FROM plants
65 | |             LEFT JOIN user_plants up on plants.id = up.plant_id and up.user_id = $1
66 | |             WHERE plants.id = $2
67 | |         "#, user_id, id)
   | |________________________^ expected enum `std::option::Option`, found struct `DateTime`
   |
   = note: expected enum `std::option::Option<DateTime<_>>`
            found struct `DateTime<_>`

If I change my struct to match:

#[derive(Clone)]
pub struct PlantWithUserDetails {
    pub id: Uuid,
    pub in_garden_at: DateTime<Utc>,
}

I end up with a runtime exception:

occurred while decoding column 4: unexpected null; try decoding as an `Option`

Caused by:
    unexpected null; try decoding as an `Option`

Info

  • SQLx version: 0.6.2
  • SQLx features enabled: ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "offline"]
  • Database server and version: Postgres 14
  • Operating system: macos
  • rustc --version: rustc 1.64.0 (a55dd71d5 2022-09-19)

jetaggart avatar Sep 29 '22 20:09 jetaggart

Please provide the relevant database schema as well as the output of EXPLAIN (VERBOSE, FORMAT JSON) <query>

abonander avatar Oct 02 '22 04:10 abonander

create table plants
(
    id         uuid                     default gen_random_uuid() not null
        primary key,
    name       text                                               not null
        constraint plants_name_uniq
            unique,
    created_at timestamp with time zone default now()             not null,
    updated_at timestamp with time zone                           not null
);

create table user_plants
(
    id         uuid                     default gen_random_uuid() not null
        primary key,
    user_id    text                                               not null,
    plant_id   uuid                                               not null
        references plants,
    created_at timestamp with time zone default now()             not null,
    updated_at timestamp with time zone                           not null,
    unique (user_id, plant_id)
);
[
  {
    "Plan": {
      "Node Type": "Nested Loop",
      "Parallel Aware": false,
      "Async Capable": false,
      "Join Type": "Left",
      "Startup Cost": 0.00,
      "Total Cost": 2.35,
      "Plan Rows": 1,
      "Plan Width": 72,
      "Output": ["plants.id", "plants.name", "plants.created_at", "plants.updated_at", "up.created_at"],
      "Inner Unique": true,
      "Join Filter": "(plants.id = up.plant_id)",
      "Plans": [
        {
          "Node Type": "Seq Scan",
          "Parent Relationship": "Outer",
          "Parallel Aware": false,
          "Async Capable": false,
          "Relation Name": "plants",
          "Schema": "public",
          "Alias": "plants",
          "Startup Cost": 0.00,
          "Total Cost": 1.29,
          "Plan Rows": 1,
          "Plan Width": 64,
          "Output": ["plants.id", "plants.name", "plants.created_at", "plants.updated_at"],
          "Filter": "(plants.id = '11c1229c-c71c-41e6-8b55-771437202390'::uuid)"
        },
        {
          "Node Type": "Seq Scan",
          "Parent Relationship": "Inner",
          "Parallel Aware": false,
          "Async Capable": false,
          "Relation Name": "user_plants",
          "Schema": "public",
          "Alias": "up",
          "Startup Cost": 0.00,
          "Total Cost": 1.04,
          "Plan Rows": 1,
          "Plan Width": 24,
          "Output": ["up.id", "up.user_id", "up.plant_id", "up.created_at", "up.updated_at"],
          "Filter": "((up.plant_id = '11c1229c-c71c-41e6-8b55-771437202390'::uuid) AND (up.user_id = '11c1229c-c71c-41e6-8b55-771437202390'::text))"
        }
      ]
    }
  }
]

jetaggart avatar Oct 02 '22 18:10 jetaggart

Can confirm the bug still exists in sqlx v0.6.2 with rustc 1.67.0-nightly.

Workaround attempt with query_as!(Option<T>, "..") fails with

comparison operators cannot be chained
use `::<...>` instead of `<...>` to specify lifetime, type, or const arguments
or use `(...)` if you meant to specify fn arguments

gitmalong avatar Dec 10 '22 16:12 gitmalong

I had a similar query that worked before adding an ORDER BY clause:

        sqlx::query!(
            "SELECT nt.id, user_id, title, subtitle
            FROM notification_types nt
                LEFT JOIN opted_out_notification_types
                    ON notification_type = nt.id AND opted_out_notification_types.user_id = $1
                ORDER BY title
            ",
            user_id
        ).fetch_all(&mut conn)
        .await
        .context("Trying to fetch all notification types")?
        .into_iter()
        .map(|r| NotificationTypeRow {
            id: r.id,
            title: r.title,
            subtitle: r.subtitle,
            opted_in: r.user_id.is_none(),
        })

The above failed to compile because user_id inferred to be i64 instead of Option<i64>

I've got a workaround by overriding the type but I rather not rely on this too much

        sqlx::query!(
            r#"SELECT nt.id, user_id as "user_id?", title, subtitle
            FROM notification_types nt
                LEFT JOIN opted_out_notification_types
                    ON notification_type = nt.id AND opted_out_notification_types.user_id = $1
                ORDER BY title
            "#,
            user_id
        )
        .fetch_all(&mut conn)
        .await
        .context("Trying to fetch all notification types")?
        .into_iter()
        .map(|r| NotificationTypeRow {
            id: r.id,
            title: r.title,
            subtitle: r.subtitle,
            opted_in: r.user_id.is_none(),
        })

Charles-Johnson avatar May 08 '23 13:05 Charles-Johnson

query_as!(Option<T>, "..")

I think with query_as! you can't be passing in an Option<T> or Vec<T> in the first position. That's what results in this error. You'd have to pass something like let foo: Option<T> = query_as!(T,...). Not sure if this helps.

jayy-lmao avatar May 18 '23 00:05 jayy-lmao

Can confirm sqlx is inferring non-Option for left joins in 0.7 as well. As a workaround, you can use SELECT foo as "foo?".

Palmik avatar Oct 04 '23 06:10 Palmik

I'm also having this issue and it seems to be caused by the WHERE condition.

For example, the column in this simple left join is correctly inferred as Option<T>:

SELECT
    foo.id,
    bar.foo_id -- Option<T>
FROM
    foo
    LEFT JOIN bar ON bar.foo_id = foo.id

But adding a WHERE condition causes it to be inferred as T:

SELECT
    foo.id,
    bar.foo_id -- T
FROM
    foo
    LEFT JOIN bar ON bar.foo_id = foo.id
WHERE
    foo.id = $1

Strangely, using AND does not cause this issue, so you can kind of use it as a hacky workaround:

SELECT
    foo.id,
    bar.foo_id -- Option<T>
FROM
    foo
    LEFT JOIN bar ON bar.foo_id = foo.id
    AND foo.id = $1

musjj avatar Oct 02 '24 18:10 musjj

Tentatively closed by #3541

abonander avatar Jul 01 '25 00:07 abonander