support "is nil" and "is not nil" and make "is empty" safer#129
Conversation
While working on an issue to reduce load the Nomad CLI can place on the server, I discovered that go-bexpr does not handle pointers in structs usefully or even safely. Without an option for "is nil", users are likely to try "is empty" on a pointer object. But an expression like "/TopValue/MaybeNilValue is empty" panics because the handler for empty only works for collections. Fortunately in Nomad we don't really trust go-bexpr not to panic and have recover handling, so this returns an error rather than crashing the control plane. Add "is nil" and "is not nil" to the grammar. Make "is empty" handle non-collections safely and do the intuitive thing when given a pointer to a struct. Ref: https://hashicorp.atlassian.net/browse/NMD-941 Ref: hashicorp/nomad#26653 Ref: hashicorp/nomad#27631
| case reflect.Struct: | ||
| // a non-nil pointer to a struct will also have this type when | ||
| // dereferenced by the caller | ||
| return false, nil | ||
| case reflect.Invalid: | ||
| // a nil pointer to a struct will have this type when dereferenced by | ||
| // the caller | ||
| return true, nil | ||
| case reflect.Pointer: | ||
| // the caller should be chasing pointers-to-pointers but handle this for | ||
| // robustness | ||
| return value.IsNil(), nil |
There was a problem hiding this comment.
Note to reviewers: arguably is empty should return an error for pointers/structs, rather than be handled as we've done it here. If we remove these cases, the user will get an error but we won't be panicking anymore. I don't have strong feelings either way on this other than end-user convenience, at the trade-off of "impurity" in the API
There was a problem hiding this comment.
My feelings are maybe a little stronger. To make your "arguably" argument:
- I interpret this thing's (panicky)
.Len() == 0as intent to work on the same types thatlen()would (as you handle in the first case), and Go won't even compile if you try tolen()the wrong type. So I think that deserves an error rather than treating a struct as "having length" and a nil pointer to a concrete type as "not having length".- The
Invalidcase seems even more like an error, because in Go that would be a runtime nil pointer panic, if I understand that right?
- The
- We can loosen constraints more easily than we can tighten them, so we could un-error this later if needed. Although, going from panic->error feels safer than going from error->not-error... I'm a little waffly on this point.
There was a problem hiding this comment.
Ok, I was feeling like I would be pedantic to say "well technically this isn't empty" but you've sold me on all of this. 😁
The Invalid case seems even more like an error, because in Go that would be a runtime nil pointer panic, if I understand that right?
Right! That's actually the case where I hit the panic in Nomad.
| }, | ||
| "default max expressions": { | ||
| expression: "((((((((foo == 1))))))))", | ||
| // typo in pigeon code-gen |
There was a problem hiding this comment.
Note to reviewers: not sure which version of pigeon fixed this bug. We don't have the version pinned in this repo, but that's out of scope for this PR.
gulducat
left a comment
There was a problem hiding this comment.
Nice addition, seems useful!
I have a ramble in the missing-key case, and I vote for IsEmpty to error on things that can not have length, as it seems more in-line with the intent of the method, and now there's an alternative in IsNil.
| case MatchIsNil: | ||
| // M["x"] is nil is true. Missing keys have no value. | ||
| return true | ||
| case MatchIsNotNil: | ||
| // M["x"] is nil is false. Missing keys have no value. | ||
| return false |
There was a problem hiding this comment.
Up front, I won't lose any sleep on this, but for pedantic conversation, MatchIsNil => true here has me torn.
I see this true/false binary aligns with the Is/Not pairs above, but here the positive case feels subtly off to me: The the absence of a value is not nil, but this reads to me like "it's there, and it's nil." I suppose if we assume M is a map of pointers, then the zero value (of a v, ok := M["x"]) will be nil, but if it's map[string]string, then the zero value is "", which is not nil! So really MatchIsNil depends on the map type, which we don't know here, so it's more like return 🤷
But a shrug isn't a bool, and I imagine that people holding this new expression will get the results they expect with these values as they are, so as I said, I won't lose sleep over it.
With that, I do think the comment on MatchIsNotNil is a tad off:
| case MatchIsNil: | |
| // M["x"] is nil is true. Missing keys have no value. | |
| return true | |
| case MatchIsNotNil: | |
| // M["x"] is nil is false. Missing keys have no value. | |
| return false | |
| case MatchIsNil: | |
| // M["x"] is nil is true. Missing keys have no value. | |
| return true | |
| case MatchIsNotNil: | |
| // M["x"] is not nil is false. Missing keys have no value. | |
| return false |
right?
There was a problem hiding this comment.
I suppose if we assume
Mis a map of pointers, then the zero value (of av, ok := M["x"]) will benil, but if it'smap[string]string, then the zero value is"", which is notnil!
There are other cases where it the value could be nil, like a map where the values are other non-primitive types that are pointers under the hood, like maps or slices (i.e. map[string]map[string]string).
I kind of think a lot of the existing behavior of this function is bogus. Ex. MatchIfEmpty returns true if the key is missing. A missing key isn't the same as the key is unset, after all, as you've noted. In my mind all of these cases should return an error. But for compatibility's sake I didn't want to have is nil work different.
Definitely right on the comment though. 👍
| pos: position{line: 113, col: 1, offset: 3209}, | ||
| pos: position{line: 113, col: 1, offset: 3238}, |
There was a problem hiding this comment.
Note for other reviewers: it took me a moment to orient myself and recognize that grammar.go is a generated file, so all these little changes are not relevant for review.
| case reflect.Struct: | ||
| // a non-nil pointer to a struct will also have this type when | ||
| // dereferenced by the caller | ||
| return false, nil | ||
| case reflect.Invalid: | ||
| // a nil pointer to a struct will have this type when dereferenced by | ||
| // the caller | ||
| return true, nil | ||
| case reflect.Pointer: | ||
| // the caller should be chasing pointers-to-pointers but handle this for | ||
| // robustness | ||
| return value.IsNil(), nil |
There was a problem hiding this comment.
My feelings are maybe a little stronger. To make your "arguably" argument:
- I interpret this thing's (panicky)
.Len() == 0as intent to work on the same types thatlen()would (as you handle in the first case), and Go won't even compile if you try tolen()the wrong type. So I think that deserves an error rather than treating a struct as "having length" and a nil pointer to a concrete type as "not having length".- The
Invalidcase seems even more like an error, because in Go that would be a runtime nil pointer panic, if I understand that right?
- The
- We can loosen constraints more easily than we can tighten them, so we could un-error this later if needed. Although, going from panic->error feels safer than going from error->not-error... I'm a little waffly on this point.
gulducat
left a comment
There was a problem hiding this comment.
Assuming tests are happy, I'm happy!
Yup! From the current head of this PR: I'll see if I can get CI fixed as well. |
While working on an issue to reduce load the Nomad CLI can place on the server, I discovered that go-bexpr does not handle pointers in structs usefully or even safely.
Without an option for "is nil", users are likely to try "is empty" on a pointer object. But an expression like
/TopValue/MaybeNilValue is emptypanics because the handler for empty only works for collections. Fortunately in Nomad we don't really trust go-bexpr not to panic and have recover handling, so this returns an error rather than crashing the control plane.Add "is nil" and "is not nil" to the grammar. Make "is empty" handle non-collections safely and do the intuitive thing when given a pointer to a struct.
Ref: https://hashicorp.atlassian.net/browse/NMD-941
Ref: hashicorp/nomad#26653
Ref: hashicorp/nomad#27631
Testing
(apparently there's no CI run on PRs in this repo? 😬 )
PCI review checklist
I have documented a clear reason for, and description of, the change I am making.
If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.
If applicable, I've documented the impact of any changes to security controls.
Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc.