sqlx icon indicating copy to clipboard operation
sqlx copied to clipboard

sqlx::query!(..) inferring wrong `Option` NULLable-types

Open DaAitch opened this issue 2 years ago • 6 comments

Environment:

  • PostgreSQL 14.1
  • sqlx = { version = "0.5", features = [ "runtime-async-std-native-tls", "postgres", "offline", "time" ] }

I don't think this issue is related to OS-specifics, so please let me know, if you need more information here.

Description

My DB schema:

CREATE TABLE department (
  id              SERIAL PRIMARY KEY,
  name            TEXT NOT NULL
);

CREATE TABLE person (
  id              SERIAL PRIMARY KEY,
  name            TEXT NOT NULL,
  department_id   INT references department(id) NOT NULL
);

CREATE TABLE training (
  id              SERIAL PRIMARY KEY,
  name            TEXT NOT NULL
);

CREATE TABLE person_training (
  trainee_person_id   INT references person(id) NOT NULL,
  training_id         INT references training(id) NOT NULL,
  PRIMARY KEY (trainee_person_id, training_id),

  advisor_person_id   INT references person(id) NULL,
  progress            REAL NOT NULL
);

CREATE TABLE person_training_log (
  id                  SERIAL PRIMARY KEY,
  trainee_person_id   INT references person(id) NOT NULL,
  training_id         INT references training(id) NOT NULL,
  author_person_id    INT references person(id) NOT NULL,
  comment             TEXT NOT NULL,
  created             TIMESTAMP NOT NULL,
  updated             TIMESTAMP NOT NULL
);

I need to make a query with a subselect and some joins, where (in my opinion) sqlx has a bug. At first here is a query which is smaller, but part of the larger query, which doesn't work. This smaller one here works and also has a left join and subselect. sqlx correctly understands, that both select fields may be NULL, so great.

        let trainings = query!(r#"
            SELECT
                ptl_last.created            AS last_log_created,
                ptl_last.comment            AS last_log_comment
            FROM
                person_training pt
            LEFT JOIN (
                SELECT
                    ptl.trainee_person_id,
                    ptl.training_id,
                    ptl.created,
                    ptl.comment,
                    ROW_NUMBER() OVER(
                        PARTITION BY
                            ptl.trainee_person_id, ptl.training_id
                        ORDER BY
                            ptl.created
                        DESC
                    ) as i
                FROM
                    person_training_log ptl
            ) ptl_last
            ON
                ptl_last.i = 1 AND
                ptl_last.training_id = pt.training_id AND
                ptl_last.trainee_person_id = pt.trainee_person_id
            WHERE
                pt.trainee_person_id = 9
        "#).fetch_all(&self.config.db_pool).await.unwrap();

        if let Some(created) = trainings[0].last_log_created {
            // ok compiles, it's an Option
        }

Now when I make the actual query it fails:

let trainings = query!(r#"
            SELECT
                t.id                        AS training_id,
                t.name                      AS training_name,
                pt.progress                 AS training_progress,
                p_a.name					AS advisor_name,
                ptl_last.created            AS last_log_created,
                ptl_last.comment            AS last_log_comment,
                p_ptl_last_a.name           AS last_log_author_name
            FROM
                person_training pt
            INNER JOIN
                training t
            ON
                pt.training_id = t.id
            LEFT JOIN
                person p_a
            ON
                p_a.id = pt.advisor_person_id
            LEFT JOIN (
                SELECT
                    ptl.trainee_person_id,
                    ptl.training_id,
                    ptl.created,
                    ptl.author_person_id,
                    ptl.comment,
                    ROW_NUMBER() OVER(
                        PARTITION BY
                            ptl.trainee_person_id, ptl.training_id
                        ORDER BY
                            ptl.created
                        DESC
                    ) as i
                FROM
                    person_training_log ptl
            ) ptl_last
            ON
                ptl_last.i = 1 AND
                ptl_last.training_id = pt.training_id AND
                ptl_last.trainee_person_id = pt.trainee_person_id
            LEFT JOIN
                person p_ptl_last_a
            ON
                p_ptl_last_a.id = ptl_last.author_person_id
            WHERE
                pt.trainee_person_id = $1
        "#, person_id)
            .fetch_all(&self.config.db_pool)
            .await
            .map_err(sqlx_error_into_status)?;

        if let Some(created) = trainings[0].last_log_created {
            // 312 |             if let Some(created) = trainings[0].last_log_created {
            //     |                    ^^^^^^^^^^^^^   ----------------------------- this expression has type `PrimitiveDateTime`
            //     |                    |
            //     |                    expected struct `PrimitiveDateTime`, found enum `Option`
            //     |
            //     = note: expected struct `PrimitiveDateTime`
            //                 found enum `Option<_>`
        }

It's almost the same context: field of a left-joined table (which should always be consider nullable I guess?). And indeed .await returns Err(..) with message "error occurred while decoding column 4: unexpected null; try decoding as an Option".

Checking generated meta data, sqlx doesn't get the nullable fields correctly.

sqlx-data.json:

{
  "a05641face9dea9a20ab8d564a723f893ffb3dc8b490cd00c0843694558caf39": {
    "describe": {
      "columns": [
        {
          "name": "last_log_created",
          "ordinal": 0,
          "type_info": "Timestamp"
        },
        {
          "name": "last_log_comment",
          "ordinal": 1,
          "type_info": "Text"
        }
      ],
      "nullable": [
        true,
        true
      ],
      "parameters": {
        "Left": []
      }
    },
    "query": "\n            SELECT\n                ptl_last.created            AS last_log_created,\n                ptl_last.comment            AS last_log_comment\n            FROM\n                person_training pt\n            LEFT JOIN (\n                SELECT\n                    ptl.trainee_person_id,\n                    ptl.training_id,\n                    ptl.created,\n                    ptl.comment,\n                    ROW_NUMBER() OVER(\n                        PARTITION BY\n                            ptl.trainee_person_id, ptl.training_id\n                        ORDER BY\n                            ptl.created\n                        DESC\n                    ) as i\n                FROM\n                    person_training_log ptl\n            ) ptl_last\n            ON\n                ptl_last.i = 1 AND\n                ptl_last.training_id = pt.training_id AND\n                ptl_last.trainee_person_id = pt.trainee_person_id\n            WHERE\n                pt.trainee_person_id = 9\n        "
  },
  "209ab5928f26319c8c7780d6ea469563086d7e09ba7d766e3441c2bbbff189ff": {
    "describe": {
      "columns": [
        {
          "name": "training_id",
          "ordinal": 0,
          "type_info": "Int4"
        },
        {
          "name": "training_name",
          "ordinal": 1,
          "type_info": "Text"
        },
        {
          "name": "training_progress",
          "ordinal": 2,
          "type_info": "Float4"
        },
        {
          "name": "advisor_name",
          "ordinal": 3,
          "type_info": "Text"
        },
        {
          "name": "last_log_created",
          "ordinal": 4,
          "type_info": "Timestamp"
        },
        {
          "name": "last_log_comment",
          "ordinal": 5,
          "type_info": "Text"
        },
        {
          "name": "last_log_author_name",
          "ordinal": 6,
          "type_info": "Text"
        }
      ],
      "nullable": [
        false,
        false,
        false,
        false,
        false,
        false,
        false
      ],
      "parameters": {
        "Left": [
          "Int4"
        ]
      }
    },
    "query": "\n            SELECT\n                t.id                        AS training_id,\n                t.name                      AS training_name,\n                pt.progress                 AS training_progress,\n                p_a.name\t\t\t\t\tAS advisor_name,\n                ptl_last.created            AS last_log_created,\n                ptl_last.comment            AS last_log_comment,\n                p_ptl_last_a.name           AS last_log_author_name\n            FROM\n                person_training pt\n            INNER JOIN\n                training t\n            ON\n                pt.training_id = t.id\n            LEFT JOIN\n                person p_a\n            ON\n                p_a.id = pt.advisor_person_id\n            LEFT JOIN (\n                SELECT\n                    ptl.trainee_person_id,\n                    ptl.training_id,\n                    ptl.created,\n                    ptl.author_person_id,\n                    ptl.comment,\n                    ROW_NUMBER() OVER(\n                        PARTITION BY\n                            ptl.trainee_person_id, ptl.training_id\n                        ORDER BY\n                            ptl.created\n                        DESC\n                    ) as i\n                FROM\n                    person_training_log ptl\n            ) ptl_last\n            ON\n                ptl_last.i = 1 AND\n                ptl_last.training_id = pt.training_id AND\n                ptl_last.trainee_person_id = pt.trainee_person_id\n            LEFT JOIN\n                person p_ptl_last_a\n            ON\n                p_ptl_last_a.id = ptl_last.author_person_id\n            WHERE\n                pt.trainee_person_id = $1\n        "
  }
}

Is it possible to have a workaround maybe without offline queries in this case, but still use the feature for the rest of the project?

Thank you Philipp

DaAitch avatar May 04 '22 00:05 DaAitch

I have a similar error with simpler setup. When using the macro (query_as!) the null-ability is not correct. When using the function (query_as) the null-ability is correct. The second one can be used as an workaround in my case.

  • sqlite3 (3.38.5 2022-05-06 15:25:27 78d9c993d404cdfaa7fdd2973fa1052e3da9f66215cff9c5540ebe55c407alt1)
  • sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "postgres", "sqlite", "offline"]}

My database schema:

CREATE TABLE tblmedia(
  id INTEGER PRIMARY KEY NOT NULL,
  title VARCHAR NOT NULL,
  raiting INTEGER,
  file_name VARCHAR NOT NULL,
  media_type TEXT NOT NULL
);

CREATE TABLE tblbook(
  id INTEGER PRIMARY KEY NOT NULL,
  author VARCHAR,

  FOREIGN KEY (id) REFERENCES tblmedia(id) ON DELETE CASCADE
);

CREATE VIEW book AS
SELECT
  m.id as id,
  m.title as title,
  b.author as author,
  m.raiting as raiting,
  m.file_name as file_name
FROM tblbook b
LEFT JOIN tblmedia m ON m.id = b.id;

The struct:

#[derive(Debug, PartialEq, Eq, sqlx::FromRow)]
pub struct Book {
    pub id: i64,
    pub author: Option<String>,
    pub title: String,
    pub raiting: Option<i64>,
    pub file_name: String,
}

The SQL queries

// Does work
sqlx::query_as(r#"SELECT id, author, title, raiting, file_name FROM book;"#)
.fetch_all(connection)
.await;

// Does not work
sqlx::query_as!(
  Book,
  r#"SELECT id, author, title, raiting, file_name FROM book"#
)
.fetch_all(connection)
.await;

The error

error[E0308]: mismatched types
  --> src/database/book.rs:33:9
   |
33 | /         sqlx::query_as!(
34 | |             Book,
35 | |             r#"SELECT id, author, title, raiting, file_name FROM book"#
36 | |         )
   | |_________^ expected struct `String`, found enum `Option`
   |
   = note: expected struct `String`
                found enum `Option<String>`

liolin avatar May 31 '22 09:05 liolin

@DaAitch @liolin would both of you please provide the output of EXPLAIN (VERBOSE, FORMAT JSON) <query> as that is what the Postgres driver uses for nullability analysis.

abonander avatar Jul 15 '22 06:07 abonander

@abonander , when I execute EXPLAIN (VERBOSE, FORMAT JSON) SELECT id, author, title, raiting, file_name FROM book; I get the following output:

[
  {
    "Plan": {
      "Node Type": "Hash Join",
      "Parallel Aware": false,
      "Async Capable": false,
      "Join Type": "Left",
      "Startup Cost": 23.73,
      "Total Cost": 49.77,
      "Plan Rows": 1270,
      "Plan Width": 104,
      "Output": ["m.id", "b.author", "m.title", "m.raiting", "m.file_name"],
      "Inner Unique": true,
      "Hash Cond": "(b.id = m.id)",
      "Plans": [
        {
          "Node Type": "Seq Scan",
          "Parent Relationship": "Outer",
          "Parallel Aware": false,
          "Async Capable": false,
          "Relation Name": "tblbook",
          "Schema": "public",
          "Alias": "b",
          "Startup Cost": 0.00,
          "Total Cost": 22.70,
          "Plan Rows": 1270,
          "Plan Width": 36,
          "Output": ["b.id", "b.author"]
        },
        {
          "Node Type": "Hash",
          "Parent Relationship": "Inner",
          "Parallel Aware": false,
          "Async Capable": false,
          "Startup Cost": 16.10,
          "Total Cost": 16.10,
          "Plan Rows": 610,
          "Plan Width": 72,
          "Output": ["m.id", "m.title", "m.raiting", "m.file_name"],
          "Plans": [
            {
              "Node Type": "Seq Scan",
              "Parent Relationship": "Outer",
              "Parallel Aware": false,
              "Async Capable": false,
              "Relation Name": "tblmedia",
              "Schema": "public",
              "Alias": "m",
              "Startup Cost": 0.00,
              "Total Cost": 16.10,
              "Plan Rows": 610,
              "Plan Width": 72,
              "Output": ["m.id", "m.title", "m.raiting", "m.file_name"]
            }
          ]
        }
      ]
    }
  }
]

liolin avatar Jul 15 '22 10:07 liolin

That's actually not a false positive, look back at the view you created:

CREATE VIEW book AS
SELECT
  m.id as id,
  m.title as title,
  b.author as author,
  m.raiting as raiting,
  m.file_name as file_name
FROM tblbook b
LEFT JOIN tblmedia m ON m.id = b.id;

Most of those columns are coming from the m table brought in by a left join, which means they may not be present.

Since you're selecting from tblbook which is foreign-keyed to tblmedia, you would always expect there to be a corresponding tblmedia row if there is a tblbook row, which would be INNER JOIN, not LEFT JOIN.

abonander avatar Jul 15 '22 19:07 abonander

Ahh. Thank you for pointing out my mistake. And I'm sorry for bothering you with this false positive.

liolin avatar Jul 20 '22 13:07 liolin

Running into something similar:

CREATE TABLE Donations (
    id INTEGER PRIMARY KEY,
    user TEXT NOT NULL,
    amount REAL NOT NULL,
    message TEXT,
    timestamp REAL NOT NULL
);

For the query I have:

 sqlx::query_as!(DonationRow,"SELECT * FROM Donations ORDER BY timestamp DESC LIMIT ?", limit)

And the struct:

#[derive(Debug)]
#[allow(dead_code)]
struct DonationRow {
    id: i64,
    user: String,
    amount: f64,
    message: Option<String>,
    timestamp: f64,
}

And the error:

error[E0308]: mismatched types
  --> src/main.rs:50:55
   |
50 |               Some(proto::DonationsOrder::Timestamp) => sqlx::query_as!(
   |  _______________________________________________________^
51 | |                 DonationRow,
52 | |                 "SELECT * FROM Donations ORDER BY timestamp DESC LIMIT ? ",
53 | |                 limit
54 | |             ),
   | |_____________^ expected `i64`, found enum `Option`
   |
   = note: expected type `i64`
              found enum `Option<i64>`
   = note: this error originates in the macro `$crate::sqlx_macros::expand_query` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0308]: mismatched types
  --> src/main.rs:50:55
   |
50 |               Some(proto::DonationsOrder::Timestamp) => sqlx::query_as!(
   |  _______________________________________________________^
51 | |                 DonationRow,
52 | |                 "SELECT * FROM Donations ORDER BY timestamp DESC LIMIT ? ",
53 | |                 limit
54 | |             ),
   | |_____________^ expected struct `String`, found enum `Option`
   |
   = note: expected struct `String`
                found enum `Option<String>`
   = note: this error originates in the macro `$crate::sqlx_macros::expand_query` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0308]: mismatched types
  --> src/main.rs:50:55
   |
50 |               Some(proto::DonationsOrder::Timestamp) => sqlx::query_as!(
   |  _______________________________________________________^
51 | |                 DonationRow,
52 | |                 "SELECT * FROM Donations ORDER BY timestamp DESC LIMIT ? ",
53 | |                 limit
54 | |             ),
   | |_____________^ expected `f64`, found enum `Option`
   |
   = note: expected type `f64`
              found enum `Option<f64>`
   = note: this error originates in the macro `$crate::sqlx_macros::expand_query` (in Nightly builds, run with -Z macro-backtrace for more info)

jeanlucthumm avatar Aug 09 '22 03:08 jeanlucthumm

I had this same problem. I ended up resolving it by explicitly marking the joined-in column as optional col as "col?".

martijnarts avatar Oct 04 '22 12:10 martijnarts

I had this same problem. I ended up resolving it by explicitly marking the joined-in column as optional col as "col?".

@0rvar

This was very helpful and worked for me, thanks. My question is, is this SQL, Postgres, or SQLX syntax? Is this documented anywhere?

This is very useful but there's no way I would have ever figured that out if I hadn't seen your comment.

Sharpiro avatar Jul 20 '23 18:07 Sharpiro

@Sharpiro I think you meant to @ someone else.

However, I believe this issue is resolved in sqlx 0.7. That has been my experience in any case. Try upgrading and see if that helps

0rvar avatar Jul 20 '23 18:07 0rvar

@Sharpiro I think you meant to @ someone else.

However, I believe this issue is resolved in sqlx 0.7. That has been my experience in any case. Try upgrading and see if that helps

Ahh sorry I meant to tag @martijnarts

But I'm not really saying there is a problem, I'm just curious about the col as "col?" syntax

Sharpiro avatar Jul 20 '23 19:07 Sharpiro

@Sharpiro this is SQLx behavior, and it's documented here.

martijnarts avatar Jul 20 '23 21:07 martijnarts

@Sharpiro this is SQLx behavior, and it's documented here.

Great thanks

Sharpiro avatar Jul 21 '23 00:07 Sharpiro

I feel sorry for opening an issue and then not actively participating for a long while.

For what I understood sqlx forwards SQL to the specific db server and get back the meta data for the query, which is a genius idea. If there are Option<..>-types where you don't expect them or the other way round, the DB seems to have taken into account more than you, or it can't understand some constraints in the SQL, so you need to put more information into the SQL, like mentioned in the comments before, so this is no issue anymore.

Thanks for clarifying.

DaAitch avatar Jul 21 '23 10:07 DaAitch

How does it work with enums?

I have last_message.message_type AS "message_type?: MessageType" but I get unexpected null; try decoding as an Option

Tienisto avatar Feb 22 '24 21:02 Tienisto