Dialect unable to cast TimeSpan to NodaTime.Duration

14 views
Skip to first unread message

Michael W Powell

unread,
Jun 27, 2025, 12:04:25 PMJun 27
to Fluent NHibernate
Hello,

I have the dialect arranging a map between TimeSpan and NodaTime.Duration. I step through several such user type conversions from and to, assembled and disassembled, so I know that at least it should be able to handle the use case.

However, still receiving an exception, not sure quite as to why, maybe something TimeSpan? Nullable is appearing in the ADO recordset somehow, I'm not sure yet.

And sorry for the mess here, the excptions, messages, and the query... The data is all there as expected, and have traced up the callstack, up to and including the repository pattern IQueryable query provider, everything else being equal, the values SHOULD be landing in the mapped property(ies) correctly.

Trouble is in an exception like this, I do not know per se "which" properties are failing. I'll look at possibly doing some Debug writes at strategic IUserType moments when those details, alias, etc, are better known. Although I'm not sure exactly that will tell me property names, more like the query alias names, which is also not especially helpful.

BTW, if it matters, the columns in question, are properly mapped in the context of a fluent Component. This is intentionally properly the case. I'll also have to review the component code, because I'm not hundred percent certain those properties are not internally or privately set, with their values either calculated or ctor provided.

Thanks!

NHibernate.Exceptions.GenericADOException: 'could not initialize a collection: [WhalleyBotEnhanced.Tracks.TrackMeetEvent.Lanes#c0832da0-d2f9-412c-8364-059f7ed205b5][SQL: SELECT lanes0_.TrackMeetEventId as trackmeeteventid3_10_1_, lanes0_.Id as id1_10_1_, lanes0_.Id as id1_10_0_, lanes0_.Enabled as enabled2_10_0_, lanes0_.TrackMeetEventId as trackmeeteventid3_10_0_, lanes0_.Name as name4_10_0_, lanes0_.Color as color5_10_0_, lanes0_.lowValuePlayerCount as lowvalueplayercount6_10_0_, lanes0_.lowValueDueTime as lowvalueduetime7_10_0_, lanes0_.lowValuePeriod as lowvalueperiod8_10_0_, lanes0_.lowValueDuration as lowvalueduration9_10_0_, lanes0_.highValuePlayerCount as highvalueplayercount10_10_0_, lanes0_.highValueDueTime as highvalueduetime11_10_0_, lanes0_.highValuePeriod as highvalueperiod12_10_0_, lanes0_.highValueDuration as highvalueduration13_10_0_, lanes0_.packCategoriesJson as packcategoriesjson14_10_0_, lanes0_.packIdsJson as packidsjson15_10_0_, lanes0_.positionsJson as positionsjson16_10_0_ FROM public.efcore_wbe_trackmeetlane lanes0_ WHERE lanes0_.TrackMeetEventId=?]'

- $exception {"could not initialize a collection: [WhalleyBotEnhanced.Tracks.TrackMeetEvent.Lanes#c0832da0-d2f9-412c-8364-059f7ed205b5][SQL: SELECT lanes0_.TrackMeetEventId as trackmeeteventid3_10_1_, lanes0_.Id as id1_10_1_, lanes0_.Id as id1_10_0_, lanes0_.Enabled as enabled2_10_0_, lanes0_.TrackMeetEventId as trackmeeteventid3_10_0_, lanes0_.Name as name4_10_0_, lanes0_.Color as color5_10_0_, lanes0_.lowValuePlayerCount as lowvalueplayercount6_10_0_, lanes0_.lowValueDueTime as lowvalueduetime7_10_0_, lanes0_.lowValuePeriod as lowvalueperiod8_10_0_, lanes0_.lowValueDuration as lowvalueduration9_10_0_, lanes0_.highValuePlayerCount as highvalueplayercount10_10_0_, lanes0_.highValueDueTime as highvalueduetime11_10_0_, lanes0_.highValuePeriod as highvalueperiod12_10_0_, lanes0_.highValueDuration as highvalueduration13_10_0_, lanes0_.packCategoriesJson as packcategoriesjson14_10_0_, lanes0_.packIdsJson as packidsjson15_10_0_, lanes0_.positionsJson as positionsjson16_10_0_ FROM public.efcore_wbe_trackmeetlane lanes0_ WHERE lane..."} NHibernate.Exceptions.GenericADOException

+ InnerException {"Unable to cast object of type 'System.TimeSpan' to type 'NodaTime.Duration'."} System.Exception {System.InvalidCastException}

Michael W Powell

unread,
Jun 27, 2025, 12:27:11 PMJun 27
to Fluent NHibernate
With a strategically placed Debug write, the component low and high values are properly being set, but the failure is happening after the owner component instance has been set, as far as I can determine.

NullSafeGet: fields: 18, names: [lowvalueduetime7_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [lowvalueperiod8_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [lowvalueduration9_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [highvalueduetime11_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [highvalueperiod12_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [highvalueduration13_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.

Which there is some property setter logic going on there, but I wonder if that should not be a simple receiver of the NH negotiated component instance. One example:

public virtual SchedulablePlayerComponentData HighValue
{
    get => _highValue;
    set
    {
        var highValue = _highValue;

        if (SetProperty(ref highValue, value ?? new()) && !ReferenceEquals(highValue, _highValue))
        {
            _highValue.AcceptValue(highValue);
        }
    }
}

So instead we just do: set => SetProperty(ref _highValue, value), and if we need to connect any business logic after that, we do that, but let NH handle the component property itself.

Not dissimilar in approach from IList<T> collections, adding model or view model observability, ObservableCollection<T> around those source collections, for proper parent-child referencing.

Thoughts?

Michael W Powell

unread,
Jun 27, 2025, 3:50:50 PMJun 27
to Fluent NHibernate
More diagnostics, I have INPC wired in my domain model, which communicates to our WPF view model, view, bindings, and so forth. Some debugging there. Can see the Id being set properly. Changed here, changing snipped for brevity, but you can see the progression, things appear to be properly set.

Although it is curious the IQueryable failure lands during the InternalPositions (ObservableCollection<Location>) INPC EH. No discernable way why that should be the case. And without explanation why the TimeSpan Duration user type is failing.

Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: Id

NullSafeGet: fields: 18, names: [lowvalueduetime7_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [lowvalueperiod8_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [lowvalueduration9_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [highvalueduetime11_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [highvalueperiod12_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
NullSafeGet: fields: 18, names: [highvalueduration13_10_0_], owner class: 'WhalleyBotEnhanced.Tracks.TrackMeetLane'.
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: Enabled
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: IsReady
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: TrackMeetEvent
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: Name
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: Color
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: LowValue
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: HighValue
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: PackCategories
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: InternalPackCategories
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: PackIds
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: InternalPackIds
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: Positions
Property changed: WhalleyBotEnhanced.Tracks.TrackMeetLane; root: WhalleyBotEnhanced.Tracks.TrackMeetLane; property: InternalPositions
Exception thrown: 'NHibernate.Exceptions.GenericADOException' in NHibernate.dll
An exception of type 'NHibernate.Exceptions.GenericADOException' occurred in NHibernate.dll but was not handled in user code

could not initialize a collection: [WhalleyBotEnhanced.Tracks.TrackMeetEvent.Lanes#c0832da0-d2f9-412c-8364-059f7ed205b5][SQL: SELECT lanes0_.TrackMeetEventId as trackmeeteventid3_10_1_, lanes0_.Id as id1_10_1_, lanes0_.Id as id1_10_0_, lanes0_.Enabled as enabled2_10_0_, lanes0_.TrackMeetEventId as trackmeeteventid3_10_0_, lanes0_.Name as name4_10_0_, lanes0_.Color as color5_10_0_, lanes0_.lowValuePlayerCount as lowvalueplayercount6_10_0_, lanes0_.lowValueDueTime as lowvalueduetime7_10_0_, lanes0_.lowValuePeriod as lowvalueperiod8_10_0_, lanes0_.lowValueDuration as lowvalueduration9_10_0_, lanes0_.highValuePlayerCount as highvalueplayercount10_10_0_, lanes0_.highValueDueTime as highvalueduetime11_10_0_, lanes0_.highValuePeriod as highvalueperiod12_10_0_, lanes0_.highValueDuration as highvalueduration13_10_0_, lanes0_.packCategoriesJson as packcategoriesjson14_10_0_, lanes0_.packIdsJson as packidsjson15_10_0_, lanes0_.positionsJson as positionsjson16_10_0_ FROM public.efcore_wbe_trackmeetlane lanes0_ WHERE lanes0_.TrackMeetEventId=?]

The core of the user type for us is TryAssemble and TryDisassemble, basically from (database) and to (database) directions. Our approach thus far is to handle the edge cases, null, intercepting the TPrimitive (P) and TUser (U), and failing those, throw an exception. Then in the derived class, override the two Try methods only focused on the specific types we want to convert ONLY. Which for the most part seems to be working well. Wondering however if there are some issues with Fluent and NH components not using this for whatever reason, which would be muy no bueno.

protected override bool TryAssemble(object cached, object owner, out object assembled)
{
    assembled = null;

    switch (cached)
    {
        case not null when cached is Duration duration:
            assembled = duration;
            break;

        case not null when cached is TimeSpan timeSpan:
            assembled = Duration.FromTicks(timeSpan.Ticks);
            break;
    }

    return assembled is not null;
}

protected override bool TryDisassemble(object value, out object disassembled)
{
    disassembled = null;

    switch (value)
    {
        case not null when value is Duration duration:
            // TODO: for now assuming TimeSpan is the intermediate record set type
            disassembled = duration.ToTimeSpan();
            break;

        case not null when value is TimeSpan timeSpan:
            disassembled = timeSpan;
            break;

        case not null:
            // TODO: just in the event we may need a DateTimeOffset use case
            throw new InvalidOperationException($"Unable to disassemble value type '{value.GetType()}'.");
    }

    return disassembled is not null;
}

And from the base class perspective. Snipped for brevity... effectively you can say the Try methods are both core to the Assemble and Disassemble methods, which are in turn central to the user type. And as long as we have primitive P and returned R types correctly aligned, it works out pretty well. P being with respect to the ADO recordset, and R being the returned or sometimes user (U) type. In this specific circumstance, TimeSpan to NodaTime.Duration.

public virtual object NullSafeGet(DbDataReader rs, string[] names, ISessionImplementor session, object owner)
{
    // after lifting the index ordinal named field...
    return value switch
    {
        DBNull or null => null,
        R r => Assemble(Disassemble(r), owner),
        P p => Assemble(p, owner),
        _ => throw new InvalidOperationException($"Unable to null safe get value type '{value.GetType()}', names [{string.Join(", ", names)}].")
    };
}

public virtual void NullSafeSet(DbCommand cmd, object value, int index, ISessionImplementor session)
{
    // after finding cmd, arg (parameter) is the correct pattern matched type and so on...
    arg.Value = value switch
    {
        DBNull or null => DBNull.Value,
        P p => p,
        R r => Disassemble(r),
        _ => throw new InvalidOperationException($"Unable to null safe set value type '{value.GetType()}', index {index}.")
    };
    // ...
}

I put breakpoints in the Try methods, and I do not see anything that jumps out as being unusual or errant there. But for whatever reason obviously a convertion is being missed.

Our component part handler is this:

protected static void OnMapSchedulablePlayerComponentData(ComponentPart<SchedulablePlayerComponentData> componentPart)
{
    componentPart.Map(x => x.PlayerCount).Not.Nullable();
    componentPart.Map(x => x.DueTime).CustomType<NpgsqlNodaTimeDurationUserType>().Not.Nullable();
    componentPart.Map(x => x.Period).CustomType<NpgsqlNodaTimeDurationUserType>().Not.Nullable();
    componentPart.Map(x => x.Duration).CustomType<NpgsqlNodaTimeDurationUserType>().Not.Nullable();
}

And in the lane map. CamelCaseColumnPrefix is something I added to camel case the prefix. Technically perhaps a nice to have.

Component(x => x.LowValue, OnMapSchedulablePlayerComponentData).CamelCaseColumnPrefix(nameof(TrackMeetLane.LowValue));
Component(x => x.HighValue, OnMapSchedulablePlayerComponentData).CamelCaseColumnPrefix(nameof(TrackMeetLane.HighValue));

As far as the map itself and the queries, verified the queries are good and returning the data they should, including in the specific ID use case.

Totaly mystery at this point why the custom types are not being handled correctly. And it would be a massive issue if we had to somehow break open the component, that would be bad.

Failing fluent NH components,  we could perhaps rethink whether component is the right answer, and do an alternate JSON based approach for the column(s) itself. We are doing that in other places and it seems  to be working correctly.

Open to other suggestions.

Best and thank you!

Michael W. Powell

Michael W Powell

unread,
Jun 27, 2025, 8:06:13 PMJun 27
to Fluent NHibernate
Without digging too far into the issue, I seem to recall having run into the question in a SO or other forum some issues around fluent NH components, custom types, and so forth.

My take aways, I know for a FACT that as long as my custom type, if also dialect mapped custom type, sees the TimeSpan properly, or DateTimeOffset in the case of NodaTime.Instant, that the mapping happens as expected.

Something about components is ignoring custom types.

After some careful consideration and planning, decided it would be easier instead fo migrate the component columns to an all emcompassing JSON object column. The update was relatively painless. And we know how to map JSON oriented columns in a custom type, no problem.

So for now, that is the postgres pivot.

However, if anyone has any further background, insights, etc, would be glad to hear them, or whether there are any plans to address the matter moving fortward, in either NH an/or fluent.

Best,

Michael W. Powell

Reply all
Reply to author
Forward
0 new messages