DateTimeOffset: same point in time but different representation

Posts   
 
    
Findev
User
Posts: 113
Joined: 08-Dec-2014
# Posted on: 14-Feb-2025 16:13:20   

Hi,

entity has a DateTimeOffset with saved value of 2015-06-01T00:00:00.0000000+03:00. So I fetch it and compare to two DateTimeOffset variables like so:

var dt1 = DateTimeOffset.Parse("2015-06-01T00:00:00.0000000+03:00");
var dt2 = DateTimeOffset.Parse("2015-05-31T23:00:00.0000000+02:00");

(e.DateActualFrom == dt1).Dump(); // prints True
(e.DateActualFrom.EqualsExact(dt1)).Dump(); // prints True

(e.DateActualFrom == dt2).Dump(); // prints True
(e.DateActualFrom.EqualsExact(dt2)).Dump(); // prints False

entity's value, dt1 and dt2 all point to the same point in time, however, representation value is different. Upon assigning the value to entity's property I think engine uses the default .Equals which makes sense to determine whether value has changed. However this doesn't help when "it's the same thing just not the representation value-wise", thus if I do e.DateActualFrom = dt2; it is ignored (== seems to just compare UtcDateTime properties which will match in this case). For strict DateTimeOffset comparison there seems to be a method EqualsExact which does take the offset part into account.

Given DateTimeOffset is used through-out the project (with manual and AutoMapper mapping back and forth), what would be the best way to actually assign the provided value with semantics of EqualsExact for change detection? From one point of view I could see ORM handling this internally as the value I'm providing is still different, while it is true that moment in time matches its representation matters as well and, in this case, once saved there's no duality.

Thank you!

Otis avatar
Otis
LLBLGen Pro Team
Posts: 39826
Joined: 17-Aug-2003
# Posted on: 15-Feb-2025 08:56:49   

The runtime indeed uses Equals to compare values. I didn't know about DateTimeOffset.EqualsExact and after reading the documentation I see it's ambiguous: Equals will tell you if the DateTimeOffset values represent the same point in time, but EqualsExact will tell you if those are also in the same timezone (as with multiple timezones you can have the same point in time but in different timezones), am I correct? (if not, I have no idea what it is for, sorry flushed )

So as this is ambiguous, the check whether values are equal therefore is ambiguous in this case and whatever we pick will have a downside.

The issue is that the check happens inside EntityCore<T>.SetValue(int fieldIndex, object value, bool performDesyncForFKFields, bool checkForRefetch), line 3105, where we call FieldUtilities.DetermineIfFieldShouldBeSet... which is a static method and which does a comparison based on Equals. There's no interception point to override the behavior there, so that makes it problematic to add the behavior you want.

Does the current behavior leads to fields being flagged as dirty while they shouldn't be flagged as dirty? I think with a bit of a workaround you could get that behavior but it's cumbersome: Override both OnSetValue() and OnSetValueComplete() in a partial class of CommonEntityBase, where you add code to check if the field is of type DateTimeOffset, and do the exact comparison in OnSetValue, and remember the value, then when OnSetValueComplete comes along, you check if the field is indeed marked dirty, and if so (and it shouldn't be marked dirty), correct the field's value in OnSetValueComplete... But admitted, it's ... not ideal

Frans Bouma | Lead developer LLBLGen Pro
Findev
User
Posts: 113
Joined: 08-Dec-2014
# Posted on: 15-Feb-2025 10:22:34   

Hi,

yes, basically the same point in time for different time zones. It doesn't set the entity as 'Dirty' is this case and basically ignores the assignment which may lead to mixed up values for the same entity. Do you think it's a good idea if LLBLGen would have this behavior configurable on global level + entity level for overrides or something like that?

I'll check the suggested OnSetValue approach and report back.

Thank you!

Findev
User
Posts: 113
Joined: 08-Dec-2014
# Posted on: 15-Feb-2025 14:23:09   

initial approach where I was setting the CurrentValue directly wasn't setting the entity itself as dirty, so while field was changed it didn't save anything, so I took some pieces from the SetValue method itself and came up with the following (at least really quick testing indicates it works):

 protected override void OnSetValue(int fieldIndex, object valueToSet, out bool cancel)
    {
        if (Fields[fieldIndex].CurrentValue is DateTimeOffset currentDateTimeOffsetValue && valueToSet is DateTimeOffset dateTimeOffsetValue)
        {
            if (currentDateTimeOffsetValue.Equals(dateTimeOffsetValue) && !currentDateTimeOffsetValue.EqualsExact(dateTimeOffsetValue))
            {
                Fields.SetCurrentValue(fieldIndex, valueToSet);
                MarkFieldsAsDirty();
            }
        }

        base.OnSetValue(fieldIndex, valueToSet, out cancel);
    }

can you please validate this so I won't run into "interesting" situations later depending on how entities might be handled simple_smile

Also, obviously, downsides:

  • this is a workaround, it does (most likely) skip the body of the FieldUtilities.DetermineIfFieldShouldBeSet's if statement, same with the if (flag). Tackles only the case when Equals passes but not the EqualsExact.

  • not configurable per entity or better(?) per entity's fields, i.e. I will have to implement own tracking mechanism.

All in all, I'm leaning towards that it's on ORM to handle this by offering easier ways to customize the comparison. Default would be to preserve the existing behavior of course, but in places where one needs to change that - should be able to. Then entity will follow the regular flow without going around the intended logic. Also I'm currently seeing the DateTimeOffset itself as an entity which should be handled only by EqualsExact when it comes to saving it in db as at this point I'm not really doing any comparisons between entities to figure out which comes first etc., I'm just asking ORM to save it as is and most likely I have a good reason for that regardless if it is the same moment in time. If needed, I'd probably just use UTC "DateTime" for that.

Thank you!

Otis avatar
Otis
LLBLGen Pro Team
Posts: 39826
Joined: 17-Aug-2003
# Posted on: 17-Feb-2025 09:31:01   

While your OnSetValue looks ok, I agree it's better if this is done behind the scenes.

A global / per entity setting isn't going to cut it tho, as you said, you might want to configure this per field and what if you have multiple fields of type DateTimeOffset and different requirements?

So we're looking into adding a virtual method that by default calls the FieldUtilities.DetermineIfFieldShouldBeSet() method, and which you can override and specify a comparison lambda with. So by default DetermineIfFieldShouldBeSet will receive a (a, b) => a.Equals(b) lambda, but you can specify in your override (a, b) => a.EqualsExact(b) in the case of a DateTimeOffset. At least that's what we think is the best option here. The main issue is that DetermineIfFieldShouldBeSet calls other functions to do its work so it ripples through and all these methods are public so it's a breaking change. Our 5.12 update isn't ready yet so if you really need it this week then we can't postpone it till 5.12.

We'll think about it some more as it's a fundamental change so I don't think we'll have a fix today but we'll see. Fix will be added silently in 5.11.5 hotfix and documented in 5.12

Frans Bouma | Lead developer LLBLGen Pro
Findev
User
Posts: 113
Joined: 08-Dec-2014
# Posted on: 17-Feb-2025 10:06:54   

I can live with my fix in prod for now, please take your time simple_smile I'm still thinking that this type should be just treated as EqualsExact when it comes to saving in db otherwise information is silently lost. But yes, doesn't make your choices simpler due to potential "what if"s in other projects.

Aight, pls ping this thread once I can update and refactor.

Thank you!

P.S.: I remember seeing comparison delegation to ValuesAreEqual method, so potentially it doesn't have to be the DetermineIfFieldShouldBeSet if I have enough metadata in ValuesAreEqual, well, all in all, you know better simple_smile

Otis avatar
Otis
LLBLGen Pro Team
Posts: 39826
Joined: 17-Aug-2003
# Posted on: 17-Feb-2025 10:33:41   

It turns out it's quite easy, we first thought we need to pass it on all the way but that's not that important, we can get away with an overload to the FieldUtilities method. We indeed now use as default the call to ValuesAreEqual, so it's just 1 method to override, receiving the fieldindex: protected virtual Func<object, object, bool> GetFieldValueComparer(int fieldIndex)

So in your partial class of the entity class or commonentitybase, you can override that method and provide a different lambda to compare the values. I think that solves your problem

It's now available in hotfix 5.11.5 simple_smile

Frans Bouma | Lead developer LLBLGen Pro
Findev
User
Posts: 113
Joined: 08-Dec-2014
# Posted on: 17-Feb-2025 10:36:52   

Quick follow up: one of the reasons regarding the global setting and overriding that per entity or its fields was to, possibly, simplify the default behavior. Say, by default setting is "use .Equals for DateTimeOffset" (for backwards compatibility) but I can change this for specific fields if I wish so, however, if I flip the setting to "use .EqualsExact for DateTimeOffset" then I can opt-out from this behavior for some fields if needed. For example in the current project I have tens of DateTimeOffset but so far I don't think I have a need (when saving to db) for using .Equals for DateTimeOffset, so I would flip the global switch while still able to override.

Thank you!

Findev
User
Posts: 113
Joined: 08-Dec-2014
# Posted on: 17-Feb-2025 10:41:04   

Oh, that was quick, I will check it out after the publish (with current fix), thank you simple_smile