graphql-platform icon indicating copy to clipboard operation
graphql-platform copied to clipboard

HC14 Strict RFC 3339 DateTime parsing should be opt-in/out - able?

Open randyridge opened this issue 1 year ago • 6 comments

Product

Hot Chocolate

Is your feature request related to a problem?

In updating from HC 13 -> 14 we encountered a runtime behavior change, with respect to dates, I see that this is documented explicitly in the migration notes and I agree that having a common datetime spec is beneficial and rfc 3339 is a reasonable choice. I also realize that 13->14 is a major semver and could be breaking. All that said, I have clients sending me dates that are not strictly compatible, and while I agree they're not doing the correct thing and can and should be fixed, it's unfortunate this new spec enforcement is coupled directly with upgrading to 14.

My proposal would be to allow the ability to opt-in to the legacy datetime parser similar to how I can do the same for the legacy node ID serializer. It gives me time to migrate clients while the server can go from 13 to 14. For dates though, I have published clients, who from their point of view are sending the same query to the same schema and are now broken just for me having updated the server from 13 to 14. I need some way to decouple the 13 -> 14 upgrade from the strict enforcement of the datetime spec the same way I can decouple the upgrade from the new ID serializer.

I'm left with few options, either the client has to change, our clients go through app approval processes on various platforms, which again I can't always force (some platforms won't force an update even if one is available), especially at an exact time (like when the hc14 server starts taking traffic), or I have to upgrade and deploy elsewhere so new clients that are sending rfc compatible dates can hit an hc 14 instance (without a schema change) and those that haven't updated can still point to the hc13 version (with the same schema) and run dual processes for some period of time.

Specifically the error was: DateTime cannot parse the given literal of type StringValueNode

For this date (missing seconds) "2024-08-23T00:00-04:00"

The solution you'd like

allow the ability to opt-in to the legacy datetime parser

randyridge avatar Aug 23 '24 18:08 randyridge

I was able to work around this by modifying your private static scalar lookups using just the right amount of unholy reflection and replacing your datetime scalar with a child of your datetime scalar that doesn't call the DateTimeScalarRegex. This is a less-than-ideal solution.

randyridge avatar Aug 28 '24 21:08 randyridge

Why dont you use BindRuntimeType?

michaelstaib avatar Aug 28 '24 22:08 michaelstaib

Is there a way to replace the DateTime scalar type that HotChcoalte register? BindRuntimeType adds a new scalar type. That cannot have the same name. This makes client generation harder down the line.

magahl avatar Sep 06 '24 15:09 magahl

builder.Services
    .AddGraphQLServer()
    .BindRuntimeType<DateTime, CustomDateTimeType>()
    .AddTypes();

CustomDateTimeType is a copy of DateTimeType, with the format check removed.

glen-84 avatar Sep 09 '24 08:09 glen-84

Yeah but that would end up with me having two types of scalars for date time?

image

Thats what i meant with problems generating clients down the line. Since they cant both have the name DateTime. It will throw an exception on startup. Key already exist. Thats why i wanted to remove the old one.

magahl avatar Sep 09 '24 09:09 magahl

I don't see that.

image

glen-84 avatar Sep 09 '24 09:09 glen-84

Same as magahl, I get this error when trying to do the workaround:

An item with the same key has already been added. Key: DateTime
at HotChocolate.Configuration.TypeInitializer.DiscoverTypes()
   at HotChocolate.Configuration.TypeInitializer.Initialize()
   at HotChocolate.SchemaBuilder.Setup.InitializeTypes(SchemaBuilder builder, IDescriptorContext context, IReadOnlyList`1 types)
   at HotChocolate.SchemaBuilder.Setup.Create(SchemaBuilder builder, LazySchema lazySchema, IDescriptorContext context)
   at HotChocolate.SchemaBuilder.Create(IDescriptorContext context)

willgittoes avatar Dec 04 '24 03:12 willgittoes

I was able to work around this by modifying your private static scalar lookups using just the right amount of unholy reflection and replacing your datetime scalar with a child of your datetime scalar that doesn't call the DateTimeScalarRegex. This is a less-than-ideal solution.

Hey @randyridge could you tell me how you did this? My approach was to try and modify the static regex in DateTimeType, but newer versions of dotnet won't let me be that naughty (because it's static readonly and the class is initialised before I can get to it)

willgittoes avatar Dec 05 '24 00:12 willgittoes

In our startup something like so:

var lookupFieldValue = typeof(Scalars).GetField("_lookup", BindingFlags.NonPublic | BindingFlags.Static)?.GetValue(null);
if(lookupFieldValue == null) {
	throw new InvalidOperationException("Could not find _lookup field on Scalars type.");
}

var lookup = (Dictionary<Type, Type>) lookupFieldValue;
lookup.Remove(typeof(DateTime));
lookup.Remove(typeof(DateTimeOffset));
lookup.Add(typeof(DateTime), typeof(LooseyGooseyDateTimeType));
lookup.Add(typeof(DateTimeOffset), typeof(LooseyGooseyDateTimeType));

var nameLookupFieldValue = typeof(Scalars).GetField("_nameLookup", BindingFlags.NonPublic | BindingFlags.Static)?.GetValue(null);
if(nameLookupFieldValue == null) {
	throw new InvalidOperationException("Could not find _nameLookup field on Scalars type.");
}

var nameLookup = (Dictionary<string, Type>) nameLookupFieldValue;
nameLookup[ScalarNames.DateTime] = typeof(LooseyGooseyDateTimeType);

and then...

using System;
using System.Globalization;
using HotChocolate.Language;
using HotChocolate.Types;

public class LooseyGooseyDateTimeType : DateTimeType {
	public LooseyGooseyDateTimeType() : base(ScalarNames.DateTime) {
	}

	public override bool TryDeserialize(object? resultValue, out object? runtimeValue) {
		switch(resultValue) {
			case null:
				runtimeValue = null;
				return true;
			case string s when TryDeserializeFromString(s, out var d):
				runtimeValue = d;
				return true;
			case DateTimeOffset dto:
				runtimeValue = dto;
				return true;
			case DateTime dt:
				runtimeValue = new DateTimeOffset(dt.ToUniversalTime(), TimeSpan.Zero);
				return true;
			default:
				runtimeValue = null;
				return false;
		}
	}

	protected override DateTimeOffset ParseLiteral(StringValueNode valueSyntax) {
		if(TryDeserializeFromString(valueSyntax.Value, out var value)) {
			return value!.Value;
		}

		throw new SerializationException("LooseyGooseyDateTimeType: Cannot parse literal.", this);
	}

	private static bool TryDeserializeFromString(string? serialized, out DateTimeOffset? value) {
		// No "Unknown Local Offset Convention" - https://www.graphql-scalars.com/date-time/#no-unknown-local-offset-convention
		if(string.IsNullOrEmpty(serialized) || serialized.EndsWith("-00:00")) {
			value = null;
			return false;
		}

		if(serialized.EndsWith('Z') && DateTime.TryParse(serialized, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var zuluTime)) {
			value = new DateTimeOffset(zuluTime.ToUniversalTime(), TimeSpan.Zero);
			return true;
		}

		if(DateTimeOffset.TryParse(serialized, out var dt)) {
			value = dt;
			return true;
		}

		value = null;
		return false;
	}
}

ymmv

randyridge avatar Dec 05 '24 14:12 randyridge

@randyridge thanks for that, I figured all that out but what I also needed to do was replace the references to it in the filtering. Because we have .UseFiltering() there are some filters that reference the original DateTimeType.

Here's how I did that:

...
            .AddFiltering<ReplaceDateTimeStrictParsingHackConvention>()

public class ReplaceDateTimeStrictParsingHackConvention : FilterConvention
{
    private static int _lookupPatched = 0;

    protected override void Configure(IFilterConventionDescriptor descriptor)
    {
        // Use reflection to patch in our relaxed DateTime scalar type, gross I know.
        // Only do this once, lock-free so the tests are a bit faster.
        if (Interlocked.CompareExchange(ref _lookupPatched, 1, 0) == 0)
        {
            var type = typeof(HotChocolate.Types.Scalars);
            var lookupField = type.GetField("_lookup", BindingFlags.NonPublic | BindingFlags.Static);
            var lookupVal = lookupField?.GetValue(null) as Dictionary<Type, Type>;
            lookupVal!.Remove(typeof(DateTime));
            lookupVal!.Remove(typeof(DateTimeOffset));
            lookupVal![typeof(DateTime)] = typeof(RelaxedParsingDateTimeType);
            lookupVal![typeof(DateTimeOffset)] = typeof(RelaxedParsingDateTimeType);

            var nameField = type.GetField("_nameLookup", BindingFlags.NonPublic | BindingFlags.Static);
            var nameVal = nameField?.GetValue(null) as Dictionary<string, Type>;
            nameVal!.Remove(ScalarNames.DateTime);
            nameVal![ScalarNames.DateTime] = typeof(RelaxedParsingDateTimeType);
        }

        // Replace other references to the strict datetime type.
        descriptor.AddDefaults();
        descriptor.Operation(-420).Name("doesNotAppearInSchema");
        descriptor
            .BindRuntimeType<DateTime, RelaxedParsingDateTimeOperationFilterInputType>()
            .BindRuntimeType<DateTime?, RelaxedParsingDateTimeOperationFilterInputType>()
            .BindRuntimeType<DateTimeOffset, RelaxedParsingDateTimeOperationFilterInputType>()
            .BindRuntimeType<DateTimeOffset?, RelaxedParsingDateTimeOperationFilterInputType>();
    }
}

willgittoes avatar Dec 05 '24 21:12 willgittoes

So we tested this whole thing and its fairly easy to swap the scalar out including filtering. Its about 3 - 4 lines of code. We have put it on our YouTube list to create a walkthrough.

michaelstaib avatar Feb 05 '25 09:02 michaelstaib

Hi @michaelstaib Can you please put those 3-4 lines of code here in the issue :)

klowdo avatar Feb 24 '25 10:02 klowdo

So we tested this whole thing and its fairly easy to swap the scalar out including filtering. Its about 3 - 4 lines of code. We have put it on our YouTube list to create a walkthrough.

YouTube != documentation. Please don't do this to us. You clearly have the fix already. Holding us hostage on a todo-list isn't helpful.

saday-st avatar Mar 04 '25 21:03 saday-st

Apologies for the delayed response.

The following should work:

builder.Services
    .AddGraphQLServer()
    .AddType<CustomDateTimeType>()
    .BindRuntimeType<DateTime, CustomDateTimeType>() // If you're also using the C# DateTime type.
    .BindRuntimeType<DateTimeOffset, CustomDateTimeType>()
    .AddFiltering(
        c => c
            .AddDefaults()
            .BindRuntimeType<DateTime, CustomDateTimeFilterInputType>() // If you're also using the C# DateTime type.
            .BindRuntimeType<DateTimeOffset, CustomDateTimeFilterInputType>())
    .AddTypes();
public sealed class CustomDateTimeFilterInputType : ComparableOperationFilterInputType<DateTimeOffset>;

glen-84 avatar Mar 05 '25 07:03 glen-84

@glen-84 The solution does not work and produces

Image

Any idea how to work around this? The datetime changes are causing us some headaches

RyanTotalEto avatar Apr 03 '25 18:04 RyanTotalEto

@RyanTotalEto are you using schema-first? or name bindings?

michaelstaib avatar Apr 04 '25 07:04 michaelstaib

Hi @michaelstaib and @glen-84 , We have just upgraded from 13 to 15 and now we have regression issues on date filters. Our clients are using queries with filters like below. I have tried the above solution by adding the filter conversion, but we still get the error:

{
  "errors": [
    {
      "message": "DateTime cannot parse the given literal of type `StringValueNode`.",
      "locations": [
        {
          "line": 2,
          "column": 13
        }
      ],
      "path": [
        "gte"
      ],
      "extensions": {
        "fieldCoordinate": "DateTimeOperationFilterInput.gte",
        "fieldType": "DateTime"
      }
    }
  ],
  "data": {
    "outputChannelLogs": null
  }
}
 sendDate: {
          gte: "2025-02-01",
          lte: "2025-02-28",
        }
descriptor
  .BindRuntimeType<DateTime, CustomDateTimeFilterInputType>() // If you're also using the C# DateTime type.
  .BindRuntimeType<DateTimeOffset, CustomDateTimeFilterInputType>();

jacodv avatar Apr 09 '25 08:04 jacodv

I see this issue is closed. I will ask on Slack as well

jacodv avatar Apr 09 '25 09:04 jacodv

Here's a working example: https://github.com/glen-84/DateTimeWorkaround/.

glen-84 avatar Apr 09 '25 09:04 glen-84

@glen-84 Unfortunately that is not a working example, it fails to build with

DateTimeWorkaround/Program.cs(11,6): error CS0121: The call is ambiguous between the following methods or properties: 'SchemaRequestExecutorBuilderExtensions.AddTypes(IRequestExecutorBuilder, params Type[])' and 'SchemaRequestExecutorBuilderExtensions.AddTypes(IRequestExecutorBuilder, params INamedType[])'

mnordland-newclassrooms avatar Apr 16 '25 21:04 mnordland-newclassrooms

It is a working example. The error that you're seeing is related to the source generator.

You can try the following:

  • Make sure that your IDE and SDK are up-to-date.
  • Delete all bin/obj directories.
  • Restart your IDE, clearing caches if necessary.

glen-84 avatar Apr 17 '25 07:04 glen-84

Finally! I got it working after spending almost a day.

Thanks to @glen-84 for providing working example, but there are few more under the hood things to do make it working in other projects.

I used to get same error as @mnordland-newclassrooms getting : DateTimeWorkaround/Program.cs(11,6): error CS0121: The call is ambiguous between the following methods or properties: 'SchemaRequestExecutorBuilderExtensions.AddTypes(IRequestExecutorBuilder, params Type[])' and 'SchemaRequestExecutorBuilderExtensions.AddTypes(IRequestExecutorBuilder, params INamedType[])'

Here are important points to follow to overcome this:

  1. Add following package to project.
<PackageReference Include="HotChocolate.Types.Analyzers" Version="15.1.5">
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  <PrivateAssets>all</PrivateAssets>
</PackageReference>

Important: add Properties/ModuleInfo.cs with the line [assembly: HotChocolate.Module("Types")]

This generates AddTypes() extension method during build and the error will go away.

Got clue from Google and here is another reference HotChocolate 13 type auto registration

  1. In case if you are having your own custom filter extensions (Refer my gist at GraphQL Hot Chocolate Custom filter operation example. Implements operations "like" , "ci_like" and "ci_equals". For case sensitive and insensitive ("ci_...")

You need to use "Using overriding the FilterConventionExtension Conventions" otherwise you will get error of type already added.

Add custom filter after AddFiltering(...)

  1. Also, if your model uses nullable DateTime, DateTime? add that as type again along with DateTime.

Here is the graphql definition from my working code:

builder.Services
        .AddGraphQLServer()
        .ModifyRequestOptions(o => o.IncludeExceptionDetails = builder.Environment.IsDevelopment())
        .ModifyCostOptions(o => o.EnforceCostLimits = false)
        .RegisterDbContextFactory<DataApiContext>() // take it for parallel execution
        .InitializeOnStartup()
        .AddType<CustomDateTimeType>()
        .BindRuntimeType<DateTime, CustomDateTimeType>()
        .BindRuntimeType<DateTime?, CustomDateTimeType>()
        .BindRuntimeType<DateTimeOffset, CustomDateTimeType>()
        .BindRuntimeType<DateTimeOffset?, CustomDateTimeType>()
        .AddTypes()
        .AddPagingArguments()
        .AddProjections()
        .AddFiltering(
                    c => c
                        .AddDefaults()
                        .BindRuntimeType<DateTime, CustomDateTimeFilterInputType>()
                        .BindRuntimeType<DateTime?, CustomDateTimeFilterInputType>()
                        .BindRuntimeType<DateTimeOffset, CustomDateTimeFilterInputType>()
                        .BindRuntimeType<DateTimeOffset?, CustomDateTimeFilterInputType>()
                        )
        .AddConvention<IFilterConvention, CustomFilterConventionExtension>() /* Using overriding the FilterConventionExtension Conventions. It has to be placed after AddFiltering()*/
        .AddSorting()
        .AddQueryType<Query>() // or use [QueryType]
        .AddMutationType<Mutation>();

tejasvih avatar Jun 13 '25 15:06 tejasvih

For anyone reading this, an option was added in 15.1.4 that allows you to disable the format check:

service.AddGraphQLServer().AddType(new DateTimeType(disableFormatCheck: true))

However, I would strongly recommend updating your clients over time, since the option may be removed in a future release. The type is supposed to represent a valid RFC 3339 date-time, and should not be used for dates only (or other incomplete date-times).

glen-84 avatar Jun 14 '25 14:06 glen-84

Thanks @glen-84, But It sincerely hope that HotChocolate minimises breaking changes and feature removals in every new version. That’s problematic many times when we are upgrading libraries for projects which are already on production.

Regards Tejasvi


From: Glen @.> Sent: Saturday, June 14, 2025 7:40:49 PM To: ChilliCream/graphql-platform @.> Cc: Tejasvi Hegde @.>; Comment @.> Subject: Re: [ChilliCream/graphql-platform] HC14 Strict RFC 3339 DateTime parsing should be opt-in/out - able? (Issue #7402)

[https://avatars.githubusercontent.com/u/261509?s=20&v=4]glen-84 left a comment (ChilliCream/graphql-platform#7402)https://github.com/ChilliCream/graphql-platform/issues/7402#issuecomment-2972784560

For anyone reading this, an option was added in 15.1.4 that allows you to disable the format check:

service.AddGraphQLServer().AddType(new DateTimeType(disableFormatCheck: true))

However, I would strongly recommend updating your clients over time, since the option may be removed in a future release. The type is supposed to represent a valid RFC 3339 date-time, and should not be used for dates only (or other incomplete date-times).

— Reply to this email directly, view it on GitHubhttps://github.com/ChilliCream/graphql-platform/issues/7402#issuecomment-2972784560, or unsubscribehttps://github.com/notifications/unsubscribe-auth/ACEDVH334MFMAXR2H32NSPL3DQUOTAVCNFSM6AAAAABNAVSNUKVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDSNZSG44DINJWGA. You are receiving this because you commented.Message ID: @.***>

tejasvih avatar Jun 14 '25 14:06 tejasvih

Breaking changes can and should be expected in major versions. This was essentially a bug fix, but it was still released in a major version and noted in the migration guide.

glen-84 avatar Jun 14 '25 14:06 glen-84

This worked! Thank you. We can now look at moving from 13 :-)

jacodv avatar Jun 18 '25 12:06 jacodv

I am already using 15. It’s working 😊

Regards Tejasvi


From: Jaco De Villiers @.> Sent: Wednesday, June 18, 2025 6:25:42 PM To: ChilliCream/graphql-platform @.> Cc: Tejasvi Hegde @.>; Comment @.> Subject: Re: [ChilliCream/graphql-platform] HC14 Strict RFC 3339 DateTime parsing should be opt-in/out - able? (Issue #7402)

[https://avatars.githubusercontent.com/u/5814127?s=20&v=4]jacodv left a comment (ChilliCream/graphql-platform#7402)https://github.com/ChilliCream/graphql-platform/issues/7402#issuecomment-2984086846

This worked! Thank you. We can now look at moving from 13 :-)

— Reply to this email directly, view it on GitHubhttps://github.com/ChilliCream/graphql-platform/issues/7402#issuecomment-2984086846, or unsubscribehttps://github.com/notifications/unsubscribe-auth/ACEDVH5GMHBZSQC725VOUJL3EFOU5AVCNFSM6AAAAABNAVSNUKVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDSOBUGA4DMOBUGY. You are receiving this because you commented.Message ID: @.***>

tejasvih avatar Jun 18 '25 14:06 tejasvih