-
Notifications
You must be signed in to change notification settings - Fork 16
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
Fix unexpected behavior for task updates regarding labels. #47
Conversation
@olsh |
Hi @AhmedZaki99 Nice catch about the bug. Well, I think that the current design (using the same class for both methods) is bad. I'd suggest creating a separate class (probably we should do this with inheritance or something) for the |
Ok, that's a good suggestion. I will push new commits soon with a proposed design for two separate |
Alright, here's a quick summary of what changed:
The best part about inheritance is that However, right after I finished the changes, I stumbled across a problem that sent me back right from where we began :(This PR started with the This is because the So for example, if we only want to update the Fix?The fix this time was much harder than the I almost spent my entire weekend on it but I finally reached a good fix. I will push commits with the proposed fix and explain it briefly in another comment. |
The purpose is to instruct the `ConverterContractResolver` to include null values for types implementing this interface, and instead, exclude instances with `IsDefault` set to true.
As a part of the implementation, a new static instance was added for each type to represent the default `non-null` value.
…default value at parent object initialization.
So, the idea of the fix for unexpected updates of the
For that purpose, I added a new interface ( The interface has only one property declaration with the following comments: /// <summary>
/// Represents a type that has a default value other than null.
/// </summary>
/// <remarks>
/// Non-null default values are typically used to be ignored during serialization
/// when null values should be included.
/// </remarks>
internal interface INonNullDefault
{
/// <summary>
/// Gets a value indicating whether the current instance represents the type's default value.
/// </summary>
bool IsDefault { get; }
} Using the information provided by that interface, the var property = base.CreateProperty(member, memberSerialization);
. . .
if (typeof(INonNullDefault).IsAssignableFrom(property.PropertyType) && member is PropertyInfo propertyInfo)
{
property.NullValueHandling = NullValueHandling.Include;
property.ShouldSerialize = instance =>
{
var value = propertyInfo.GetValue(instance, null) as INonNullDefault;
return value?.IsDefault != true; // Serialize null and non-default values (as long as IsDefault isn't true).
};
}
return property; And to implement the public class DueDate : INonNullDefault
{
/// <summary>
/// A <see cref="DueDate"/> instance that represents the non-null default value.
/// </summary>
internal static readonly DueDate Default = new DueDate();
bool INonNullDefault.IsDefault => this == Default;
. . . Finally, we update properties of type [JsonProperty("due")]
public DueDate DueDate { get; set; } = DueDate.Default;
. . .
[JsonProperty("duration")]
public Duration Duration { get; set; } = Duration.Default; And that makes each of these properties ignored by default in outgoing requests unless the user sets them to So, what do you think, is there anything I might be missing? @olsh |
… getter with `TimeSpan.Zero`. This accounts for cases where the `TimeValue` property getter is called for the `Duration.Default` instance.
So that by default, `INonNullDefault` properties are only set to thier `Default` instances outside the scope of Json deserialization, which uses internal parameterless constructors.
|
Quick note.
|
Well, that approach is slightly better than the previous one. But a couple of things are concerning me.
This different behavior is confusing too. And it can potentially lead to a hard-to-spot bug. For instance when you used I suggest that we separate the two classes and then we have two approaches for the
var updateItem = UpdateItemBuilder
.WithDuration(duration)
.WithPriority(2)
.WithEmptyResponsible()
.Build();
var updateItemFromExistingItem =
UpdateItemBuilder
// Copy all supported properties of the item to builder and return the UpdateItemBuilder instance
.ToUpdateItemBuilder()
.WithEmptyDueDate()
.WithPriority(4);
Please let's discuss all the stuff before implementation. Or probably we can improve your solution somehow. Thank you for your time. 👍🏻 |
One more solution to the problem. public class UpdateItem
{
private Duration _duration;
private bool _durationSpecified;
public Duration Duration
{
get => _duration;
set
{
_duration = value;
_durationSpecified = true;
}
}
public void UnsetDuration()
{
_durationSpecified = false;
Duration = null;
}
// JSON NET ShouldSerialize pattern
internal bool ShouldSerializeDuration()
{
return _durationSpecified;
}
} Please let me know what you think. BTW, one more disadvantage of updateItem.Duration = Duration.Default; With this, user should know about the |
Well, it's true that the The other two solutions you suggested are also great, I thought as well about using boolean flags and property setters (the third solution) but was leaving that solution as a last approach because it contains lots of boilerplate, similar to MVVM property-changed notifiers. I had the most ambition with the As for separating I will notify you when I start working on the Thank you for the great work 👍 |
I agree that the backing field approach has much boilerplate code. But on the other hand, the approach is most straightforward for end users and the API is basic.
That would be great! |
@olsh Since our last discussion here, I've faced this same issue with a couple of other projects, where I needed a way to send The core of this approach is the following interface: public interface IUnsetProperties
{
[JsonIgnore]
HashSet<PropertyInfo> UnsetProperties { get; }
} This interface, once implemented by a model, can be used to hold information for properties you want to unset (i.e. send It's fairly easy to implement by most of the models using a base class like this: public class ModelBase : IUnsetProperties
{
HashSet<PropertyInfo> IUnsetProperties.UnsetProperties { get; } = [];
} And then in a public static void IncludeUnsetProperties(JsonTypeInfo typeInfo)
{
// There's no use for this contract if the default ignore condition is set to Never.
// Also, this contract can't handle fields when they are included.
if (typeInfo.Options.DefaultIgnoreCondition is JsonIgnoreCondition.Never || typeInfo.Options.IncludeFields)
{
return;
}
if (!typeof(IUnsetProperties).IsAssignableFrom(typeInfo.Type))
{
return;
}
foreach (var propertyInfo in typeInfo.Properties)
{
propertyInfo.ShouldSerialize = (obj, value) =>
{
if (value != null)
{
// If the property has no setter delegate, it's assumed that it's a readonly property.
return !propertyInfo.Options.IgnoreReadOnlyProperties || propertyInfo.Set != null;
}
return ((IUnsetProperties)obj).UnsetProperties
.Any(name => GetPropertyName(name, typeInfo.Options) == propertyInfo.Name);
};
}
}
private static string GetPropertyName(PropertyInfo property, JsonSerializerOptions options)
{
var propertyNameAttribute = property.GetCustomAttribute<JsonPropertyNameAttribute>();
return propertyNameAttribute?.Name
?? options.PropertyNamingPolicy?.ConvertName(property.Name)
?? property.Name;
} Finally, to make it easy to use, we add this extension method: public static class UnsetPropertiesExtensions
{
public static void Unset<T, TProp>(this T entity, Expression<Func<T, TProp>> propertyExpression) where T : IUnsetProperties
{
var propertyInfo = (propertyExpression.Body as MemberExpression)?.Member as PropertyInfo
?? throw new ArgumentException($"Invalid property expression: ({propertyExpression})", nameof(propertyExpression));
// Make sure that the property is null.
propertyInfo.SetValue(entity, null);
entity.UnsetProperties.Add(propertyInfo);
}
} And usage would be like this for example: var itemInfo = await client.Items.GetAsync(item.Id);
itemInfo.Item.Unset(i => i.DueDate);
await client.Items.UpdateAsync(itemInfo.Item); So, what do you think about this approach? I have been using it for a while and it worked just fine. |
So, with this approach we can call the |
@olsh As for the possibility of passing any expression without being notified before runtime, the same issue is present with Entity Framework methods, for example, the following method call is legit at compilation: await _dbContext.Users.Include(user => user.ToString()).ToListAsync(); But then it's easy to catch these runtime errors as we provide helpful details in the exception: throw new ArgumentException($"Invalid property expression: ({propertyExpression})", nameof(propertyExpression)); It's not the best approach I know, but I think it's better than other approaches we have thought of. |
Well, I think this is pretty good solution. Let's implement this.
|
You are right, we would need that public static void Unset<T, TProp>(this T entity, Expression<Func<T, TProp>> propertyExpression)
where T : IUnsetProperties
where TProp : class I will start working on the new commits now. |
@AhmedZaki99
|
@olsh We also might want to unset a property of type that we don't own. |
Good point, OK, let’s implement this as you suggested. |
@olsh
What I did is that I used two separate classes for the You can take a look at the changed tests to see how this new design could be used with the |
One last thing, var client = TodoistClientFactory.Create(_outputHelper);
var item = ...
await client.Items.AddAsync(item);
try
{
// Run tests here.
}
finally
{
await client.Items.DeleteAsync(item.Id);
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, let's fix SonarQube issues.
Thank you for the great job.
This reverts commit 2e9d89f.
README.md
Outdated
## Migrating to v7 | ||
|
||
### Sending null values when updating entities. | ||
When updating entities, Todoist API only updates properties included in the request body, using a `PATCH` request style. | ||
That's why all properties with `null` values are not included by default, to allow updating without fetching the entity first. | ||
When updating entities, **Todoist API** only updates properties included in the request body, using a `PATCH` request style. | ||
That's why all properties with `null` values are not included by default, to allow updating without fetching the entity first, | ||
since including `null` properties will update them to `null`. | ||
|
||
So, if you want to send a `null` value to the API, you need to use the `Unset` extension method. | ||
- Up until **version 6**, properties like `DueDate` where always included the request body even if set to `null`, to allow users to reset due dates. | ||
- Starting from **version 7**, however, if you want to send a `null` value to the API, you need to use the `Unset` extension method, | ||
which could be used with all properties, not only `DueDate`s. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@olsh
It's a little bit confusing for me to find a proper way to explain this new change, your opinion\contribution here would be very helpful :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's always a good idea to include a piece of code with comments to show how the Unset method works.
Something like
// create an item with due date
...
// unset the value and update the item
...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what I added, what do you think?
For example, the following code will not have effect starting from v7 ❌
// This code won't have an effect. var task = new UpdateItem("TASK_ID"); task.DueDate = null; await client.Items.UpdateAsync(task);However, using
Unset
will send adue
property with anull
value ✔️// This code removes a task's due date. var task = new UpdateItem("TASK_ID"); task.Unset(t => t.DueDate); await client.Items.UpdateAsync(task);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ouch, I missed that :)
I think there is no need to give an example for the previous version. We aren't going to support it anyway.
The example for the new version is pretty much self-explanatory. 👍🏻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, should I then remove the ## Migrating to v7
header and comments about migration, or leave it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think we can remove the comments too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done 👍
I think we are ready to merge the PR when the analysis will be green. |
Unfortunately, I am not aware of other ways to check if default(TProp) == null SonarCloud complains about comparing So, SonarCloud kind of missed the point here :) |
Let's ignore this one and fix other issues. |
Done 👍 |
|
@AhmedZaki99 thank you! 👍🏻 |
Problem:
Id
are not required for update requests, and if not given, would not affect or update the existing values.ItemsCommandService.UpdateAsync
or similar methods, we expect that only the properties we set for theItem
object will be updated, and the rest is ignored.Item.Labels
property, instead, we have to get the fullItem
body to ensure all labels are present in theItem.Labels
collection, or all labels would be removed by the request if the property is left unchanged.Reason:
The unexpected behavior explained above was a result of the
Item.Labels
property initialization in theItem
constructor:And since any non-null property is sent as an argument with the sync command,
ItemsCommandService.UpdateAsync
sends a command to update the task with an empty collection of labels unintentionally based on the initialized value.Fix:
The fix consists of two parts:
internal
access modifier from theItem.Labels
property setter to allow users from external assemblies to modify the initialized value, potentially tonull
, if needed.Labels
property initialization with an empty collection from theItem
constructor, making itnull
by default.The reason why this fix is split into two commits is because the second one potentially represents a braking change!
Labels
property tonull
whenever they wanted to update tasks without affecting the existing labels.Labels
becamenull
by default, as users most likely would expect before updating, and when users need to create or update anItem
with labels, they need to set the collection itself rather than adding values to it, or aNullReferenceException
would be thrown.So, any user of this library would need to make that change to avoid the
NullReferenceException
:From
To