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

Make a generic equals constraint #4845

Open
stevenaw opened this issue Sep 28, 2024 · 8 comments
Open

Make a generic equals constraint #4845

stevenaw opened this issue Sep 28, 2024 · 8 comments

Comments

@stevenaw
Copy link
Member

stevenaw commented Sep 28, 2024

As part of expanding generics support for the constraints API, we would need to make the equals constraint generic.
This would be one piece of the larger effort described in #53

@stevenaw stevenaw added this to the 4.3 milestone Sep 28, 2024
@stevenaw stevenaw removed this from the 4.3 milestone Oct 6, 2024
@stevenaw
Copy link
Member Author

stevenaw commented Oct 6, 2024

@nunit/framework-team Looking for thoughts on timing here. Given that Is.EqualTo() returns EqualConstraint then I think this could be added in a non-breaking way if we extended EqualConstraint like so:

EqualConstraint<T> : EqualConstraint

However this would also mean we must redeclare all methods on the generic constraint via new. I'm not a huge fan of this approach, however I think the alternative would be to incur a binary breaking change by changing the return type. A non-breaking extend-only change could of course be corrected later by updating EqualConstraint<T> to inherit directly from Constraint which would then at that time be a breaking change.

@manfred-brands
Copy link
Member

EqualConstraint is a catch all bucket and has modifiers for all possible use cases. It has a .Seconds property which doesn't make sense on all but DateTime/TimeSpan types and then only if previously a .Within(double) was used.

We can never make a good generic API which restricts modifiers when deriving from that.

Please see my remarks: #4848 (comment) and #4848 (comment) for a different approach as demonstrated in PRs: #4847, #4848, #4853

@stevenaw
Copy link
Member Author

stevenaw commented Oct 7, 2024

I see them serving different, complimentary needs myself. For example, two ValueTuples of (int, string) would have the individual properties compared as part of NUnit equality checks. For such a case it would be nice to be able to have a single generic constraint which supported both numeric and string modifiers, among others. A comparison over two ints on the other hand would of course not be affected by string modifiers which could be a great case for type-specialized constraints.

I would, however, suggest we aim to agree upon similar approaches in how we do it in order to keep the API and behaviours consistent.

I think so far we have approached things differently. My own aim for minimizing breaking changes on a catch-all constraint seems to have worked syntactically but has brought with it some challenges that can't be fully resolved without a breaking change later. If we wanted to consider binary or source breaking changes as part of yours and my current work then I would be interested in hearing some thoughts from the broader team as well.

@manfred-brands
Copy link
Member

For example, two ValueTuples of (int, string) would have the individual properties compared as part of NUnit equality checks. For such a case it would be nice to be able to have a single generic constraint which supported both numeric and string modifiers, among others.

My changes don't affect this. For all complex cases we will fallback to the existing EqualConstraint which allows all modifiers.
I want to address to common cases, simple instances of string, numerics and Dates.
That my PRs break cases that don't make sense or even would throw runtime exceptions, I see as a plus.

@stevenaw
Copy link
Member Author

stevenaw commented Oct 8, 2024

Agreed! I think our PRs tackle different use cases and needs.

However, as both our PRs would either benefit from or require breaking changes to implement, I'd been hoping to hear from another @nunit/framework-team member their thoughts on timing for such changes. A few options I'd see for when the changes could happen:

  • Immediately within 4.3
  • In a future 4.x release, perhaps coinciding for if/when we drop a net6.0 build in favour of a net8.0 build
  • Restricted to 5.0

I would say we should try and limit the number of releases with breaking changes, whatever that may look like, in order to simplify upgrade paths.

@manfred-brands To be clear: I think there's a lot of value with your type-specialization PRs. I just think it's worth discussing when we do them to ensure we're all in agreement on timing.

@OsirisTerje
Copy link
Member

OsirisTerje commented Oct 8, 2024

@stevenaw @manfred-brands

That my PRs break cases that don't make sense or even would throw runtime exceptions, I see as a plus.

The second case here, runtime exceptions, if a PR prevents that, it is not a breaking change to me. The first case, don't make sense , assuming this is like EqualTo(42).Within(5).Seconds and similar stuff, I would also not regard as a breaking change, at least not in a type-safe world.
But, people could write something like: int seconds = SomeMethod.CalculateNumberOfSeconds(); and then expect the Within(5).Seconds to express something, which imho it doesn't. In that respect it would be breaking, but the code is very badly written, and the Seconds don't really do anything useful there, so I would still argue - this is not breaking. And I agree with @manfred-brands it is actually a plus, since it has moved errors from runtime to compile time.

I have not gone into the details of all of what you're discussing, but can we split the stuff into:

  1. May or may not be a breaking change. The unsure group.
  2. Obviously non-breaking
  3. The case above, "moving errors from runtime to compile time"
  4. Obviously breaking

Everything is in 0 at start, then move it into one of the three as we know and learn more.

For 1) and 2) , the PRs go into the 4.X release train.
For 3) the PRs go into a new vNext branch (in this case 5.X) and we keep that updated with the 4.X changes as we move along. And then plan for a 5.0 release as soon as we have a generic solution, and things are straightened up.

I see no problem with major version changes. We should just collect up more than one thing in a major release. Otherwise we can surely have 1-4 major releases a year.

That said, I don't think we should waste efforts (if that is what it is - some of what I understand from some writing above), in making things temporarily work. I would prefer to just add that to the vNext train and move on. (Ref #4845 (comment))

If we wanted to consider binary or source breaking changes as part of yours and my current work then I would be interested in hearing some thoughts from the broader team as well. (Ref #4845 (comment))

I would go for the breaking changes, if it is not obvious and simple to do it with a non-breaking change, but then put it into the vNext train.

What we did with the 3 to 4 version change was pretty good. We had a good migration guide. And, we used the analyzer to help people. We could do the same again with 4 to 5.

PS: For the 5.0 we should put out a beta version on nuget.org. It gets more traction than the alpha myget packages we have.

PS2: I noticed that the transfer to 4 took quite some time for people. The pick up of 4. was slow in the beginning, but lately it has really picked up. The 3.13.3 is still the one that have the most downloads last 6 weeks, but now 4.1 is second. 4.2.2 is also picking up speed quickly.

image

@stevenaw
Copy link
Member Author

stevenaw commented Oct 9, 2024

Thanks @OsirisTerje and neat to see the uptake of 4 increasing.

FWIW I think we're all in agreement on the "shifting of runtime errors to compiler errors", especially in the context of tests as it will likely result in a non-passing test either way. Something I'm a little less clear on would be something like below:

[Test]
public void MyTest() => Assert.That(1, Is.EqualTo(1).IgnoreCase);

The IgnoreCase modifier will currently be ignored instead of throwing an exception here, meaning this test will "pass" on 4.2.2. I'm unclear if we'd still want to consider a change within the 4.x band which would change this from a passing test to a compiler error. We do have the analyzers to catch these sort of things, but not all users will have installed them and those that do will see this raised as a Warning by default instead of an Error.

EDIT: Though reading your description again, I think this falls into category 3 as the existing behaviour is not a runtime error?

@stevenaw
Copy link
Member Author

Returning back to the catch-all generic constraint, I think it would also likely be easier if done as a breaking change in vNext. At which point I think it could be a nice time to try and get generics as far into the constraints as possible.

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

Successfully merging a pull request may close this issue.

3 participants