Skip to content
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

When using .optional(), convert a QueryBuilderError into None #4019

Merged
merged 16 commits into from
May 18, 2024

Conversation

dessalines
Copy link
Contributor

@dessalines dessalines commented May 7, 2024

This is my first diesel PR, I apologize in advance for any issues. Let me know of any documentation I missed, tests I should add, or anything I can do to improve it.

I also had to comment a few tests in bin/test to get this to pass, which leads me to believe the main branch has some unaddressed errors which might fail CI before reaching the test I added.

Related discussion: #4018

@@ -234,6 +234,28 @@ fn update_with_no_changes() {
.unwrap();
}

#[test]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this test is just copied from the one above, it might make sense to add the new line just above the panic one above. Or just keep it as its own test, up to you.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to have a separate test for this. These tests are quite fast so there is no point to worry about anything.

Copy link
Member

@weiznich weiznich left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structure of the change looks ok, although I'm quite unhappy with the new matching there as it allows a potentially large number of different error. That's not something I would like to support there.

@@ -284,6 +292,7 @@ impl<T> OptionalExtension<T> for QueryResult<T> {
match self {
Ok(value) => Ok(Some(value)),
Err(Error::NotFound) => Ok(None),
Err(Error::QueryBuilderError(_)) => Ok(None),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't want to allow such a wide match here. We should only allow that one specific error that happens for empty updates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a String, should I match that exact string then?

Copy link
Member

@Ten0 Ten0 May 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to make that into an error that we may match on, then we probably want to turn it into a some form of typed error: it's less error prone (typos, error message updates...), and does not imply the additional runtime cost that string comparisons would imply.

The type already allows that:
image

So if it's currently a String (or &'static str rather) that we then turn into a Box<dyn StdError + ...>, we probably want to create a dedicated type for it (with the same message as before in its Display impl) and match it with .is::<_>(), as is done with UnexpectedNullError in the QueryableByName code for Option.

diesel/diesel/src/result.rs

Lines 393 to 403 in df89687

/// An unexpected `NULL` was encountered during deserialization
#[derive(Debug, Clone, Copy)]
pub struct UnexpectedNullError;
impl fmt::Display for UnexpectedNullError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Unexpected null for non-null column")
}
}
impl StdError for UnexpectedNullError {}

Err(e) if e.is::<crate::result::UnexpectedNullError>() => Ok(None),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking the same, I'd be glad to do this if its desired.

@weiznich weiznich requested review from Ten0 and a team May 8, 2024 18:28
Copy link
Member

@Ten0 Ten0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this topic and opening this PR! 😊
It does indeed look like it would be useful to be able to catch this error in an ergonomic manner. 🙂

However mixing it within the existing .optional() extension feels odd to me: it feels like it might make it harder to use .optional() without mixing in potential unrelated/unexpected scenarios into None.
I find that it is typically better to avoid falling back from larger error categories than may really happen, as this may turn into silent fails, resulting in hard-to-debug issues.

As far as I'm concerned we have many use-cases where we use the update(table.find(id)).filter(make_sure_that_state_is_ok()).optional() combination, where make_sure_that_state_is_ok() is for us to get single-query happy path, and then if None re-query to know whether the ID was wrong or the state was wrong and return the appropriate user error.

However we have no use-case such as the one described by this issue.

This tends to make me believe that it's better to be able to guarantee that .optional really only catches the scenario I'm personally always looking for where make_sure_that_state_is_ok() is FALSE, to avoid potentially enforcing upon users the unexpected silent fail scenario described above, so I'd probably prefer if it stayed dedicated to the current meaning it has. I think a design that wouldn't have this downside of too-large-matching, but would still have the same ergonomics as the solution currently proposed by this PR, would be introducing a separate function similar to .optional() that catches precisely that empty changeset scenario. (.optional_empty_changeset()?)

However as weiznich pointed out here, .optional() is just an extension trait that is reasonably easy to re-create outside of Diesel. (We do have a few similar extension traits ourselves, such as .get_only_result() which is a get_result that errors if there are multiple results, which is an extremely common case for us, prevents bugs where one would forget to write the appropriate .find or .filter clause (again because hard errors are preferable to silent fails that would cause incorrect behavior)... and have found that having that in our diesel_helpers crate doesn't generate any friction.)
So overall I have no strong opinion on whether the extension trait itself should even live within Diesel itself - I guess it depends on really how common this use-case is, as this is a compromise between providing the feature and API&Doc size/maintenance cost, and all I can say is I personally haven't encountered this use-case.

However what seems pretty clear that we should do within Diesel is adding a typed error for this and exporting it in a similar way to UnexpectedNullError, so that users can actually match this error and construct any extension trait related to this that they see fit, in an idiomatic and ~typed manner, (that is, not by matching on a string which the API does not guarantee may not change and is slow).

@dessalines
Copy link
Contributor Author

.optional_empty_changeset()

I'd be fine with this also, although I'd probably prefer .optional() catching this error case already.

guess it depends on really how common this use-case is,

Its at least common in our case, to build form large form objects without knowing beforehand which fields we're receiving are updated, because they come from data sources outside our control. We've added some hacky code to check individually that all fields are not None to avoid this error (and you're right also that we could make an additional extension on top of optional. So making sure this case is accounted for in .optional(), rather than throwing an error, is much safer for us than doing a .ok() on the result.

@Ten0
Copy link
Member

Ten0 commented May 10, 2024

Ok so next steps for this PR:

  • Create the new error type and have the relevant code export it
  • Get opinion from @diesel-rs/reviewers on whether it's worth including the extension trait in Diesel itself
  • If so, add that as well

@Ten0 Ten0 closed this May 11, 2024
@Ten0 Ten0 reopened this May 11, 2024
@Ten0
Copy link
Member

Ten0 commented May 11, 2024

Sorry, missclick

@dessalines dessalines marked this pull request as draft May 14, 2024 14:18
@dessalines dessalines marked this pull request as draft May 14, 2024 14:18
@dessalines dessalines marked this pull request as ready for review May 14, 2024 17:17
@dessalines dessalines requested review from weiznich and Ten0 May 14, 2024 17:17

/// Expected when an update has no changes to save
#[derive(Debug, Clone, Copy)]
pub struct EmptyChangeset;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if set should be capitalized or not.

@@ -228,10 +227,30 @@ fn update_with_no_changes() {
name: None,
hair_color: None,
};
update(users::table)
let update_result = update(users::table).set(&changes).execute(connection);
assert!(update_result.is_err());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this from a panic check, to an is_err check, since this now returns QueryBuilder(EmptyChangeset)

Copy link
Member

@weiznich weiznich left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks fine for me beside the comment about the breaking change to the OptionalExtension trait. I think it's safer to just introduce a new trait for that + reexport that from the prelude.

diesel/src/result.rs Show resolved Hide resolved
@@ -234,6 +234,28 @@ fn update_with_no_changes() {
.unwrap();
}

#[test]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to have a separate test for this. These tests are quite fast so there is no point to worry about anything.

Copy link
Member

@weiznich weiznich left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good for me with the small documentation fix applied.

@@ -45,6 +45,8 @@ pub enum Error {
/// An example of when this error could occur is if you are attempting to
/// construct an update statement with no changes (e.g. all fields on the
/// struct are `None`).
///
/// When using `optional_empty_changeset`, this error is turned into `None`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note: That comment is not correct anymore. It might be a got idea to move this comment to the new error type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

K, will do.

diesel/src/result.rs Outdated Show resolved Hide resolved
@weiznich weiznich enabled auto-merge May 18, 2024 05:53
@weiznich weiznich added this pull request to the merge queue May 18, 2024
Merged via the queue into diesel-rs:master with commit 3c7b7c4 May 18, 2024
48 checks passed
@JMLX42
Copy link

JMLX42 commented May 22, 2024

@weiznich can we please have a minor release for this?

@weiznich
Copy link
Member

@JMLX42 As this is a new feature it needs to wait until the next diesel feature release happens. As for the timeline for such a release: This is a free time project, so that will happen as soon as it is ready. We generally do not give ETA's there. You can contribute or sponsor the development to make such a release happen faster.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants