New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Our type system can panic when comparing nullable values #104

Closed
sgrif opened this Issue Jan 17, 2016 · 4 comments

Comments

Projects
None yet
1 participant
@sgrif
Member

sgrif commented Jan 17, 2016

I should note that this is relatively hard to actually demonstrate in normal code. The issue here is that x = NULL in SQL returns NULL. I've managed to distill it into the following test case:

#[test]
fn test_comparing_null() {
    use diesel::prelude::*;
    use diesel::select;
    use diesel::expression::AsExpression;
    use diesel::types::{Nullable, Integer};

    let query = select(AsExpression::<Nullable<Integer>>::as_expression(None::<i32>).eq(Some(1)));
    query.get_result::<bool>(&connection()).unwrap();
}

The query here is ultimately

SELECT NULL = 1;

The problem is that the sql type of Eq<T, U> is always Bool, and so we panic on an "unexpected null value" when trying to read it. We need to have the type of Eq<Nullable<T>, Nullable<U>> to be Nullable<Bool>.

I should also note that this only applies to these sorts of expressions appearing in a select clause. It does not affect us in places like where clauses. Also as an aside, in places like where clauses, NULL is effectively the same as false which is part of why I'm leaning towards the second answer.

There are basically two answers here:

  • We change the type of the expression to be Nullable<Bool> where appropriate. This will almost certainly require specialization, and I'm not actually sure that specialization as proposed today will suffice.
  • We have the impl FromSql<Bool> for bool treat NULL as false. I'm actually fine with this, if we can be sure that infix predicates which would otherwise return a boolean are the only case where null can bubble up like this. It should be noted that this problem applies to all infix predicates, not just =. This also applies to > and AND for example.
@sgrif

This comment has been minimized.

Member

sgrif commented Jan 17, 2016

I've raised questions on the specialization RFC related to this. I think the real answer under specialization might involve adding a NotNull trait and IsNullable trait (redundant with Nullable<T>, maybe potentially replacing it with T: NativeSqlType + IsNullable), and having the following:

impl<T, U> Expression for Eq<T, U> where
    T: Expression,
    U: Expression<SqlType=T::SqlType>,
    T::SqlType: NotNull,
{
    type SqlType = Bool;

    fn to_sql(...) { ... }
}

impl<T, U> Expression for Eq<T, U> where
    T: Expression,
    U: Expression<SqlType=T::SqlType>,
    T::SqlType: IsNullable,
{
    type SqlType = Nullable<Bool>;

    fn to_sql(...) { // identical to previous }
}

impl<T, U> Expression for Eq<T, U> where
    T: Expression,
    U: Expression<SqlType=T::SqlType>,
    T::SqlType: IsNullable + NotNull,
{
    type SqlType = ();

    fn to_sql(...) { unreachable!("No type should impl NotNull and IsNullable"); }
}
@sgrif

This comment has been minimized.

Member

sgrif commented Jan 17, 2016

As I think through this, I'm becoming more confident that this problem is scoped entirely too booleans coming from infix predicates. While technically you can run into the same problem with SELECT 1 + NULL, we don't actually implement Add for Nullable<Anything>. So another way to put this is that the problem only applies in cases where we have an unbounded constraint on the input types. At first glance, that appears to only apply to infix predicates which return a boolean (and IsNull/IsNotNull, but this doesn't apply to those for obvious reasons).

Given the idea of "truthy"/"falsey" in other languages, I think that treating NULL as false is a reasonably comfortable conversion. This would not remove the idea of a Nullable<Bool>, which you could certainly still have as a column. It would only affect cases that we at present cannot correctly handles, which is nullable_expression.eq(other_nullable_expression).

As a side note, if we ignore other backends, this problem is not solved by just mapping Eq to IS DISTINCT FROM in PG. IS DISTINCT FROM is not treated as an operator, so we cannot do things like x IS DISTINCT FROM ANY(some_array). The problem also applies to cases like > and <.

I'm starting to become amicable to this over a solution which changes the type to Nullable<Bool>, as unless we suddenly decide to allow T to be comparable to Nullable<T>, which we currently prevent by design, any case involving NULL would bubble up through AND in painful ways. For example x.eq(y).and(nullable_x.eq(nullable_y)) would not compile, as both sides would need to be Nullable.

Other cases where this does apply in SQL are essentially functions, and operators like +, which do have bounded input. We don't currently have elegant handling of multiple types for functions, but for operators like +, it's completely possible with our types as set up today to have Nullable<Integer> + Nullable<Integer> return Nullable<Integer>. What is not possible today is to have Integer + Nullable<Integer> (looking through the commit history, I'm not sure I fully understand why I wrote Rhs as an associated type, and not a type parameter. Presumably it's due to wanting to use AsExpression, and the inability to say impl<Rhs> ::std::ops::Add<Rhs> where { there is exactly one type for which "AsExpression" is implemented on Rhs, and ::diesel::ops::Add exists } but I need to confirm.

@sgrif

This comment has been minimized.

Member

sgrif commented Jan 17, 2016

Interesting point from IRC: We could actually handle Eq similarly to Add, where we have a EqResult trait of some kind with a known output. This would require adding brute force impls for every native type, but we can remove a lot of that pain with macros, similarly to how we handle Add for numeric types, and Queriable/AsExpression for primitive types. This would also enable us to add equality comparisons that we don't support today like varchar.eq(text). We'd still have the same x.eq(y).and(nullable_x.eq(nullable_y)), but we might be able to figure out some way to implement impl<T: Expression<SqlType=Bool>> AsExpression<Nullable<Bool>> for T that doesn't overlap with our existing impl<T: Expression> AsExpression<T::SqlType> for T

@sgrif sgrif added the bug label Jan 17, 2016

@sgrif sgrif added this to the 1.0 milestone Jan 17, 2016

@sgrif

This comment has been minimized.

Member

sgrif commented Jan 18, 2016

I've decided to pull the trigger on treating NULL as false in this particular case during deserialization. I'm reasonably sure that this can only manifest itself with booleans, and I'm comfortable with that coercion for that type in particular as it essentially mimics it's behavior in boolean contexts in SQL outside of a select clause.

We should continue to keep an eye on this and make sure it cannot manifest itself for any types other than bool.

I believe we might be able to fix this with specialization by specializing SelectableExpression rather than Expression. Something like this:

impl<T, U, QS, ST> SelectableExpression<QS, Bool> for Eq<T, U> where
    ST: NotNull,
    T: SelectableExpression<QS, ST>,
    U: SelectableExpression<QS, ST>,
{
}
impl<T, U, QS, ST> SelectableExpression<QS, Nullable<Bool>> for Eq<T, U> where
    ST: NotNull,
    T: SelectableExpression<QS, Nullable<ST>>,
    U: SelectableExpression<QS, Nullable<ST>>,
{
}

@sgrif sgrif closed this in 26720d7 Jan 18, 2016

sgrif added a commit that referenced this issue Feb 15, 2017

Use associated types for `SelectableExpression`
The `SelectableExpression` trait serves two purposes for us. The first
and most important role it fills is to ensure that columns from tables
that aren't in the from clause cannot be used. The second way that we
use it to make columns which are on the right side of a left outer join
be nullable.

There were two reasons that we used a type parameter instead of an
associated type. The first was to make it so that `(Nullable<X>,
Nullable<Y>)` could be treated as `Nullable<(X, Y)>`. We did this
because the return type of `users.left_outer_join(posts)` should be
`(User, Option<Post>)`, not `(User, Post)` where every field of `Post`
is an `Option`.

Since we now provide a `.nullable()` method in the core DSL, I think we
can simply require calling that method explicitly if you want that tuple
conversion to occur. I think that the most common time that conversion
will even be used is when the default select clause is used, where we
can just handle it for our users automatically.

The other reason that we went with a type parameter originally was that
it was easier, since we can provide a default value for a type parameter
but not an associated type. This turned out to actually be a drawback,
as it led to #104. This PR actually brings back aspects of that issue,
which I'll get to in a moment.

It's expected that any expression which implements
`SelectableExpression<QS>` have a `T: SelectableExpression<QS>` bound
for each of its parts. The problem is, the missing second parameter is
defaulting to `T::SqlType`, which means we are implicitly saying that
this bound only applies for `QS` which does not change the SQL type
(anything except a left outer join). This ultimately led to #621.

However, with our current structure, it is impossible to fix #621
without re-introducing at least some aspects of #104. In
#104 (comment) I
said that we didn't need to worry about `1 + NULL`, because we didn't
implement add for any nullable types. However, I'm not sure I considered
joins when I made that statement. The statement applied to joins
previously because of that implicit "sql type doesn't change"
constraint. This commit removes that constraint, meaning #104 will be
back at least when the nullability comes from being on the right side of
a left join.

I don't think this is a serious enough issue that we need to immediately
address it, as the types of queries which would cause the issue still
just don't happen in practice. We should come up with a long term plan
for it, though. Ultimately the nullability of a field really only
matters in the select clause. Since any operation on null returns null,
and you basically want null to act as false in the where clasue, it
doesn't matter there.

So one partial step we could take is to break this out into two separate
traits. One for the "make sure this is valid given the from clause", and
one for the "make this nullable sometimes" case and only constrain on
the first one in the where clause. We could then re-add the "sql type
doesn't change" constraint on the problem cases, which will bring back
aspects of #621, but only for select clauses which is a smaller problem.

I'm not sure if I ultimately want to go the two traits route or not. If
nothing else, the problem cases are much more obvious with this commit.
Anywhere that has `type SqlTypeForSelect = Self::SqlType` is likely a
problem case when joins are involved. This will make it easier to find
all the places to apply a solution when I come up with one that I'm
happy with.

Fixes #621.

sgrif added a commit that referenced this issue Feb 26, 2017

Split `SelectableExpression` into two traits
The change in #709 had the side effect of re-introducing #104.
With the design that we have right now, nullability isn't propagating
upwards. This puts the issue of "expressions aren't validating that the
type of its arguments haven't become nullable, and thus nulls are
slipping in where they shouldn't be" at odds with "we can't use complex
expressions in filters for joins because the SQL type changed".

This semi-resolves the issue by restricting when we care about
nullability. Ultimately the only time it really matters is when we're
selecting data, as we need to enforce that the result goes into an
`Option`. For places where we don't see the bytes in Rust (filter,
order, etc), `NULL` is effectively `false`.

This change goes back to fully fixing #104, but brings back a small
piece of #621. I've changed everything that is a composite expression to
only be selectable if the SQL type hasn't changed. This means that you
won't be able to do things like
`users.left_outer_join(posts).select(posts::id + 1)`, but you will be
able to use whatever you want in `filter`.

This change is also to support what I think will fix the root of all
these issues. The design of "Here's the SQL type on this query source"
is just fundamentally not what we need. There is only one case where the
type changes, and that is to become null when it is on the right side of
a left join, the left side of a right join, or either side of a full
join.

One of the changes that #709 made was to require that you explicitly
call `.nullable()` on a tuple if you wanted to get `Option<(i32,
String)>` instead of `(Option<i32>, Option<String>)`. This has worked
out fine, and isn't a major ergonomic pain. The common case is just to
use the default select clause anyway. So I want to go further down this
path.

The longer term plan is to remove `SqlTypeForSelect` entirely, and *not*
implement `SelectableExpression` for columns on the nullable side of a
join. We will then provide these two blanket impls:

```rust
impl<Left, Right, T> SelectableExpression<LeftOuterJoin<Left, Right>>
    for Nullable<T> where T: SelectableExpression<Right>,
{}

impl<Left, Right, Head, Tail> SelectableExpression<LeftOuterJoin<Left, Right>>
    for Nullable<Cons<Head, Tail>> where
        Nullable<Head>: SelectableExpression<LeftOuterJoin<Left, Right>>,
        Nullable<Tail>: SelectableExpression<LeftOuterJoin<Left, Right>>,
{}
```

(Note: Those impls overlap. Providing them as blanket impls would
require rust-lang/rust#40097. Providing them as
non-blanket impls would require us to mark `Nullable` and possibly
`Cons` as `#[fundamental]`)

The end result will be that nullability naturally propagates as we want
it to. Given `sql_function!(lower, lower_t, (x: Text) -> Text)`, doing
`select(lower(posts::name).nullable())` will work. `lower(posts::name)`
will fail because `posts::name` doesn't impl `SelectableExpression`.
`lower(posts::name.nullable())` will fail because while
`SelectableExpression` will be met, the SQL type of the argument isn't
what's expected. Putting `.nullable` at the very top level naturally
follows SQL's semantics here.

sgrif added a commit that referenced this issue Feb 26, 2017

Split `SelectableExpression` into two traits
The change in #709 had the side effect of re-introducing #104.
With the design that we have right now, nullability isn't propagating
upwards. This puts the issue of "expressions aren't validating that the
type of its arguments haven't become nullable, and thus nulls are
slipping in where they shouldn't be" at odds with "we can't use complex
expressions in filters for joins because the SQL type changed".

This semi-resolves the issue by restricting when we care about
nullability. Ultimately the only time it really matters is when we're
selecting data, as we need to enforce that the result goes into an
`Option`. For places where we don't see the bytes in Rust (filter,
order, etc), `NULL` is effectively `false`.

This change goes back to fully fixing #104, but brings back a small
piece of #621. I've changed everything that is a composite expression to
only be selectable if the SQL type hasn't changed. This means that you
won't be able to do things like
`users.left_outer_join(posts).select(posts::id + 1)`, but you will be
able to use whatever you want in `filter`.

This change is also to support what I think will fix the root of all
these issues. The design of "Here's the SQL type on this query source"
is just fundamentally not what we need. There is only one case where the
type changes, and that is to become null when it is on the right side of
a left join, the left side of a right join, or either side of a full
join.

One of the changes that #709 made was to require that you explicitly
call `.nullable()` on a tuple if you wanted to get `Option<(i32,
String)>` instead of `(Option<i32>, Option<String>)`. This has worked
out fine, and isn't a major ergonomic pain. The common case is just to
use the default select clause anyway. So I want to go further down this
path.

The longer term plan is to remove `SqlTypeForSelect` entirely, and *not*
implement `SelectableExpression` for columns on the nullable side of a
join. We will then provide these two blanket impls:

```rust
impl<Left, Right, T> SelectableExpression<LeftOuterJoin<Left, Right>>
    for Nullable<T> where T: SelectableExpression<Right>,
{}

impl<Left, Right, Head, Tail> SelectableExpression<LeftOuterJoin<Left, Right>>
    for Nullable<Cons<Head, Tail>> where
        Nullable<Head>: SelectableExpression<LeftOuterJoin<Left, Right>>,
        Nullable<Tail>: SelectableExpression<LeftOuterJoin<Left, Right>>,
{}
```

(Note: Those impls overlap. Providing them as blanket impls would
require rust-lang/rust#40097. Providing them as
non-blanket impls would require us to mark `Nullable` and possibly
`Cons` as `#[fundamental]`)

The end result will be that nullability naturally propagates as we want
it to. Given `sql_function!(lower, lower_t, (x: Text) -> Text)`, doing
`select(lower(posts::name).nullable())` will work. `lower(posts::name)`
will fail because `posts::name` doesn't impl `SelectableExpression`.
`lower(posts::name.nullable())` will fail because while
`SelectableExpression` will be met, the SQL type of the argument isn't
what's expected. Putting `.nullable` at the very top level naturally
follows SQL's semantics here.

sgrif added a commit that referenced this issue Mar 3, 2017

Split `SelectableExpression` into two traits
The change in #709 had the side effect of re-introducing #104.
With the design that we have right now, nullability isn't propagating
upwards. This puts the issue of "expressions aren't validating that the
type of its arguments haven't become nullable, and thus nulls are
slipping in where they shouldn't be" at odds with "we can't use complex
expressions in filters for joins because the SQL type changed".

This semi-resolves the issue by restricting when we care about
nullability. Ultimately the only time it really matters is when we're
selecting data, as we need to enforce that the result goes into an
`Option`. For places where we don't see the bytes in Rust (filter,
order, etc), `NULL` is effectively `false`.

This change goes back to fully fixing #104, but brings back a small
piece of #621. I've changed everything that is a composite expression to
only be selectable if the SQL type hasn't changed. This means that you
won't be able to do things like
`users.left_outer_join(posts).select(posts::id + 1)`, but you will be
able to use whatever you want in `filter`.

This change is also to support what I think will fix the root of all
these issues. The design of "Here's the SQL type on this query source"
is just fundamentally not what we need. There is only one case where the
type changes, and that is to become null when it is on the right side of
a left join, the left side of a right join, or either side of a full
join.

One of the changes that #709 made was to require that you explicitly
call `.nullable()` on a tuple if you wanted to get `Option<(i32,
String)>` instead of `(Option<i32>, Option<String>)`. This has worked
out fine, and isn't a major ergonomic pain. The common case is just to
use the default select clause anyway. So I want to go further down this
path.

The longer term plan is to remove `SqlTypeForSelect` entirely, and *not*
implement `SelectableExpression` for columns on the nullable side of a
join. We will then provide these two blanket impls:

```rust
impl<Left, Right, T> SelectableExpression<LeftOuterJoin<Left, Right>>
    for Nullable<T> where T: SelectableExpression<Right>,
{}

impl<Left, Right, Head, Tail> SelectableExpression<LeftOuterJoin<Left, Right>>
    for Nullable<Cons<Head, Tail>> where
        Nullable<Head>: SelectableExpression<LeftOuterJoin<Left, Right>>,
        Nullable<Tail>: SelectableExpression<LeftOuterJoin<Left, Right>>,
{}
```

(Note: Those impls overlap. Providing them as blanket impls would
require rust-lang/rust#40097. Providing them as
non-blanket impls would require us to mark `Nullable` and possibly
`Cons` as `#[fundamental]`)

The end result will be that nullability naturally propagates as we want
it to. Given `sql_function!(lower, lower_t, (x: Text) -> Text)`, doing
`select(lower(posts::name).nullable())` will work. `lower(posts::name)`
will fail because `posts::name` doesn't impl `SelectableExpression`.
`lower(posts::name.nullable())` will fail because while
`SelectableExpression` will be met, the SQL type of the argument isn't
what's expected. Putting `.nullable` at the very top level naturally
follows SQL's semantics here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment