From 94e0cd5d8a612c53c83935b8020221c078218507 Mon Sep 17 00:00:00 2001 From: Mohit G Date: Mon, 7 Sep 2026 14:40:08 +0530 Subject: [PATCH 1/2] Upgrade Hot Chocolate to 16.6.4 --- docs/design/HC16-upgrade.md | 29 ++- src/Directory.Packages.props | 12 +- .../Mutations/CreateMutationBuilder.cs | 23 +- .../GraphQLBuilder/MutationBuilderTests.cs | 228 +++++++++++++++++- .../UnitTests/SqlQueryStructureHelperTests.cs | 6 +- 5 files changed, 279 insertions(+), 19 deletions(-) diff --git a/docs/design/HC16-upgrade.md b/docs/design/HC16-upgrade.md index a77d8149bb..68c274c5ce 100644 --- a/docs/design/HC16-upgrade.md +++ b/docs/design/HC16-upgrade.md @@ -3,7 +3,8 @@ This is a high-level companion to PR #3480. The goal here is to give a reader the *why* behind each non-obvious code change without forcing them to diff Hot Chocolate v13/v14 against v16 themselves. For the line-level changes, read the -PR. +PR. Sections 1-13 describe the original migration to 16.0.0; section 14 covers +the follow-up to 16.6.4. --- @@ -230,3 +231,29 @@ notable test changes are: `BYTE_TYPE` (`UnsignedByte` → `byte_types`) and `BYTEARRAY_TYPE` (`Base64String` → `bytearray_types`) because the GraphQL scalar name no longer matches the test database column-name root. + +--- + +## 14. Follow-up to 16.6.4 + +The follow-up updates all six centrally managed Hot Chocolate packages from +16.0.0 to 16.6.4. This crosses minor versions, not just patch versions, and +requires two compatibility changes. + +Hot Chocolate validates GraphQL input defaults against their field types. +SQL default constraints contain database expressions such as `((1))`, +`(getdate())`, or `('Placeholder')`, not GraphQL literals. Copying those +expressions into create-input defaults can prevent the entire schema from +building. + +SQL create fields with database defaults therefore remain nullable but no +longer have a GraphQL input default. Omitting a field lets the database +evaluate its default; supplying an explicit `null` remains distinct and is +still subject to database constraints. This applies to nested and linking +create inputs as well. The source `@defaultValue` metadata is preserved, +and Cosmos GraphQL literal defaults and stored-procedure parameter defaults +are unchanged. + +The `FragmentSpreadNode` constructor used in one unit test also needs the +new explicit arguments collection. An empty collection preserves the +argument-free fragment spread used by that test. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 56523d0a94..afa9d3e276 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,12 +13,12 @@ - - - - - - + + + + + + diff --git a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs index c2a9b0a9ac..f63ed20a03 100644 --- a/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/CreateMutationBuilder.cs @@ -60,7 +60,7 @@ public static class CreateMutationBuilder // 1. Scalar input fields. IEnumerable scalarInputFields = objectTypeDefinitionNode.Fields .Where(field => IsBuiltInType(field.Type) && !IsAutoGeneratedField(field)) - .Select(field => GenerateScalarInputType(name, field, IsMultipleCreateOperationEnabled)); + .Select(field => GenerateScalarInputType(name, field, databaseType, IsMultipleCreateOperationEnabled)); // Add scalar input fields to list of input fields for current input type. // Generate the create input type only if there are any scalar fields that are not auto-generated fields. @@ -191,7 +191,7 @@ private static InputObjectTypeDefinitionNode GenerateCreateInputTypeForNonRelati { if (IsBuiltInType(field.Type)) { - return GenerateScalarInputType(name, field); + return GenerateScalarInputType(name, field, databaseType); } string typeName = RelationshipDirectiveType.Target(field); @@ -269,17 +269,26 @@ private static bool DoesFieldHaveReferencingFieldDirective(FieldDefinitionNode f /// Name of the field. /// Field definition. /// Database type - /// Indicates whether multiple create operation is enabled - private static InputValueDefinitionNode GenerateScalarInputType(NameNode name, FieldDefinitionNode fieldDefinition, bool isMultipleCreateOperationEnabled = false) + /// Indicates whether multiple create operation is enabled + private static InputValueDefinitionNode GenerateScalarInputType( + NameNode name, + FieldDefinitionNode fieldDefinition, + DatabaseType databaseType, + bool isMultipleCreateOperationEnabled = false) { IValueNode? defaultValue = null; + bool isFieldNullable = false; if (DefaultValueDirectiveType.TryGetDefaultValue(fieldDefinition, out ObjectValueNode? value)) { - defaultValue = value.Fields[0].Value; - } + isFieldNullable = true; - bool isFieldNullable = defaultValue is not null; + // SQL defaults are expressions evaluated by the database, not GraphQL input defaults. + if (!IsRelationalDb(databaseType)) + { + defaultValue = value.Fields[0].Value; + } + } if (isMultipleCreateOperationEnabled && DoesFieldHaveReferencingFieldDirective(fieldDefinition)) diff --git a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs index a1478093dd..9a013f9056 100644 --- a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs @@ -5,13 +5,20 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Text.Json; +using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives; using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql; +using Azure.DataApiBuilder.Service.Services; using Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Helpers; +using HotChocolate; +using HotChocolate.Execution; using HotChocolate.Language; using HotChocolate.Types; using Humanizer; @@ -989,12 +996,13 @@ type Baz @model(name:""Baz""){ [DataRow(true, "boolean", "Boolean")] [DataRow(1.2f, "float", "Float")] [TestCategory("Mutation Builder - Create")] - public void CreateMutationWillHonorDefaultValue(object defaultValue, string fieldName, string fieldType) + public async Task CreateMutationWillHonorDefaultValue(object defaultValue, string fieldName, string fieldType) { + string defaultLiteral = JsonSerializer.Serialize(defaultValue); string gql = @$" type Foo @model(name:""Foo"") {{ - id: {fieldType}! @defaultValue(value: {{ {fieldName}: {(defaultValue is string ? $"\"{defaultValue}\"" : defaultValue)} }}) + id: {fieldType}! @defaultValue(value: {{ {fieldName}: {defaultLiteral} }}) }} "; @@ -1012,8 +1020,220 @@ type Foo @model(name:""Foo"") {{ InputObjectTypeDefinitionNode createFooInput = (InputObjectTypeDefinitionNode)mutationRoot.Definitions.First(d => d is InputObjectTypeDefinitionNode node && node.Name.Value == "CreateFooInput"); - // Serialization has them as strings, so we'll just do string compares - Assert.AreEqual(defaultValue.ToString(), createFooInput.Fields[0].DefaultValue.Value); + Assert.AreEqual(defaultLiteral, createFooInput.Fields[0].DefaultValue.ToString()); + + Dictionary capturedInput = null; + IRequestExecutor executor = CreateSchemaBuilderForCreateInputs(mutationRoot) + .AddResolver("Query", "inspect", context => + { + capturedInput = context.ArgumentValue>("item"); + return true; + }) + .Create() + .MakeExecutable(); + + OperationResult result = (await executor.ExecuteAsync("{ inspect(item: {}) }")).ExpectOperationResult(); + Assert.AreEqual(0, result.Errors.Count, string.Join(" | ", result.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.AreEqual(defaultLiteral, JsonSerializer.Serialize(capturedInput["id"]), + "A typed Cosmos default must still be supplied when the input field is omitted."); + } + + [DataTestMethod] + [DataRow(DatabaseType.MSSQL, typeof(int), "((1))", "7", false)] + [DataRow(DatabaseType.MSSQL, typeof(int), "(rand())", "7", false)] + [DataRow(DatabaseType.MSSQL, typeof(double), "((0))", "1.25", false)] + [DataRow(DatabaseType.MSSQL, typeof(decimal), "((1.5))", "1.25", false)] + [DataRow(DatabaseType.MSSQL, typeof(string), "('Placeholder')", "\"provided\"", false)] + [DataRow(DatabaseType.MSSQL, typeof(string), "(suser_sname())", "\"provided\"", true)] + [DataRow(DatabaseType.MSSQL, typeof(bool), "((1))", "false", false)] + [DataRow(DatabaseType.MSSQL, typeof(DateTime), "(getdate())", "\"2026-01-01T00:00:00Z\"", false)] + [DataRow(DatabaseType.MSSQL, typeof(DateTime), "(dateadd(day,(1),getdate()))", "\"2026-01-01T00:00:00Z\"", false)] + [DataRow(DatabaseType.MSSQL, typeof(DateTimeOffset), "(sysdatetimeoffset())", "\"2026-01-01T00:00:00Z\"", false)] + [DataRow(DatabaseType.MSSQL, typeof(Guid), "(newid())", "\"de305d54-75b4-431b-adb2-eb6b9e546014\"", false)] + [DataRow(DatabaseType.MySQL, typeof(int), "42", "7", false)] + [DataRow(DatabaseType.PostgreSQL, typeof(int), "nextval('counter_seq'::regclass)", "7", false)] + [DataRow(DatabaseType.DWSQL, typeof(int), "((42))", "7", false)] + [TestCategory("Mutation Builder - Create")] + [TestCategory("GraphQL Schema Builder")] + public async Task SqlDefaultExpressionsCreateValidInputSchema( + DatabaseType databaseType, + Type systemType, + string defaultExpression, + string suppliedLiteral, + bool isNullable) + { + ObjectTypeDefinitionNode objectType = GenerateSqlObjectTypeWithDefault( + systemType, defaultExpression, isNullable, databaseType); + DocumentNode mutationRoot = MutationBuilder.Build( + new DocumentNode(new[] { objectType }), + new() { { "Foo", databaseType } }, + new(new Dictionary { { "Foo", GenerateEmptyEntity() } }), + entityPermissionsMap: _entityPermissions); + + Dictionary capturedInput = null; + IRequestExecutor executor = CreateSchemaBuilderForCreateInputs(mutationRoot) + .AddResolver("Query", "inspect", context => + { + // Use DAB's extraction path; HC's dictionary coercion fills omitted nullable fields with null. + IDictionary parameters = ExecutionHelper.GetParametersFromSchemaAndQueryFields( + context.Selection.Field, context.Selection.RequireFieldNode(), context.Variables); + Assert.IsInstanceOfType>(parameters["item"]); + IEnumerable fields = (IEnumerable)parameters["item"]; + InputObjectType inputType = ExecutionHelper.InputObjectTypeFromIInputField(context.Selection.Field.Arguments["item"]); + capturedInput = fields.ToDictionary( + field => field.Name.Value, + field => ExecutionHelper.ExtractValueFromIValueNode( + field.Value, inputType.Fields[field.Name.Value], context.Variables)); + return true; + }) + .Create() + .MakeExecutable(); + + OperationResult omitted = (await executor.ExecuteAsync("{ inspect(item: { id: 1 }) }")).ExpectOperationResult(); + Assert.AreEqual(0, omitted.Errors.Count, string.Join(" | ", omitted.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.IsFalse(capturedInput.ContainsKey("defaulted"), + "An omitted SQL column must remain absent so the database, not GraphQL, evaluates its default."); + + OperationResult explicitNull = (await executor.ExecuteAsync("{ inspect(item: { id: 1, defaulted: null }) }")).ExpectOperationResult(); + Assert.AreEqual(0, explicitNull.Errors.Count, string.Join(" | ", explicitNull.Errors.Select(error => error.ToString()))); + Assert.IsTrue(capturedInput.ContainsKey("defaulted"), "Explicit null must remain distinct from omission."); + Assert.IsNull(capturedInput["defaulted"], "A SQL default must not replace an explicitly supplied null."); + + OperationResult supplied = (await executor.ExecuteAsync( + $"{{ inspect(item: {{ id: 1, defaulted: {suppliedLiteral} }}) }}")).ExpectOperationResult(); + Assert.AreEqual(0, supplied.Errors.Count, string.Join(" | ", supplied.Errors.Select(error => error.ToString()))); + Assert.IsTrue(capturedInput.ContainsKey("defaulted")); + Assert.IsNotNull(capturedInput["defaulted"], "An explicit value must survive input coercion."); + + capturedInput = null; + OperationResult missingRequired = (await executor.ExecuteAsync("{ inspect(item: {}) }")).ExpectOperationResult(); + Assert.IsTrue(missingRequired.Errors.Count > 0, "A non-null column without a default must still be required."); + Assert.IsNull(capturedInput, "Invalid input must be rejected before invoking a resolver."); + + InputObjectTypeDefinitionNode input = mutationRoot.Definitions.OfType() + .Single(node => node.Name.Value == "CreateFooInput"); + Assert.IsNull(input.Fields.Single(field => field.Name.Value == "defaulted").DefaultValue); + Assert.IsFalse(input.Fields.Single(field => field.Name.Value == "defaulted").Type.IsNonNullType()); + Assert.IsTrue(input.Fields.Single(field => field.Name.Value == "id").Type.IsNonNullType()); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + [TestCategory("Mutation Builder - Create")] + [TestCategory("GraphQL Schema Builder")] + public async Task SqlDefaultsRemainOptionalForMultipleAndLinkingCreate(bool createMultiple) + { + DocumentNode relationshipTypes = Utf8GraphQLParser.Parse(""" + type Foo @model(name: "Foo") { + bars: [Bar!]! @relationship(target: "Bar", cardinality: "many") + } + type Bar @model(name: "Bar") { + id: Int! @primaryKey + } + """); + ObjectTypeDefinitionNode source = GenerateSqlObjectTypeWithDefault(typeof(int), "((42))", false, DatabaseType.MSSQL); + source = source.WithFields(source.Fields.Concat(relationshipTypes.Definitions.OfType().First().Fields).ToArray()); + ObjectTypeDefinitionNode target = relationshipTypes.Definitions.OfType().Last(); + string linkingTypeName = GraphQLNaming.GenerateLinkingNodeName("Foo", "Bar"); + ObjectTypeDefinitionNode linking = GenerateSqlObjectTypeWithDefault( + typeof(double), "((0.5))", false, DatabaseType.MSSQL, linkingTypeName, "royalty_percentage") + .WithDirectives(Array.Empty()); + + Entity sourceEntity = GenerateEmptyEntity() with + { + Relationships = new() + { + { "bars", new EntityRelationship(Cardinality.Many, "Bar", new[] { "id" }, new[] { "id" }, + "dbo.foo_bar", new[] { "foo_id" }, new[] { "bar_id" }) } + } + }; + DocumentNode mutationRoot = MutationBuilder.Build( + new DocumentNode(new[] { source, target, linking }), + new() { { "Foo", DatabaseType.MSSQL }, { "Bar", DatabaseType.MSSQL } }, + new(new Dictionary + { + { "Foo", sourceEntity }, + { "Bar", GraphQLTestHelpers.GenerateEntityWithSingularPlural("Bar", "Bars") } + }), + entityPermissionsMap: _entityPermissions, + IsMultipleCreateOperationEnabled: true); + + string mutationName = createMultiple ? "createFoos" : "createFoo"; + string argumentName = createMultiple ? MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME : MutationBuilder.ITEM_INPUT_ARGUMENT_NAME; + IRequestExecutor executor = CreateSchemaBuilderForCreateInputs(mutationRoot, mutationName) + .AddResolver("Query", "inspect", _ => true) + .Create() + .MakeExecutable(); + + string item = "{ id: 1, bars: [{ id: 2 }] }"; + string input = createMultiple ? $"[{item}]" : item; + OperationResult omitted = (await executor.ExecuteAsync($"{{ inspect({argumentName}: {input}) }}")).ExpectOperationResult(); + Assert.AreEqual(0, omitted.Errors.Count, string.Join(" | ", omitted.Errors.Select(error => error.ToString()))); + + item = "{ id: 1, bars: [{ id: 2, royalty_percentage: null }] }"; + input = createMultiple ? $"[{item}]" : item; + OperationResult explicitNull = (await executor.ExecuteAsync($"{{ inspect({argumentName}: {input}) }}")).ExpectOperationResult(); + Assert.AreEqual(0, explicitNull.Errors.Count, string.Join(" | ", explicitNull.Errors.Select(error => error.ToString()))); + + item = "{ id: 1, bars: [{}] }"; + input = createMultiple ? $"[{item}]" : item; + OperationResult missingRequired = (await executor.ExecuteAsync($"{{ inspect({argumentName}: {input}) }}")).ExpectOperationResult(); + Assert.IsTrue(missingRequired.Errors.Count > 0, "Non-defaulted fields on the linking input must remain required."); + + InputObjectTypeDefinitionNode linkingInput = mutationRoot.Definitions.OfType() + .Single(node => node.Name.Value == CreateMutationBuilder.GenerateInputTypeName(linkingTypeName).Value); + InputValueDefinitionNode defaultedField = linkingInput.Fields.Single(field => field.Name.Value == "royalty_percentage"); + Assert.IsNull(defaultedField.DefaultValue); + Assert.IsFalse(defaultedField.Type.IsNonNullType()); + Assert.AreEqual("Float", defaultedField.Type.NamedType().Name.Value); + } + + private static ObjectTypeDefinitionNode GenerateSqlObjectTypeWithDefault( + Type systemType, + string defaultExpression, + bool isNullable, + DatabaseType databaseType, + string entityName = "Foo", + string defaultFieldName = "defaulted") + { + SourceDefinition table = new(); + table.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int), IsNullable = false }); + table.Columns.Add(defaultFieldName, new ColumnDefinition + { + SystemType = systemType, + IsNullable = isNullable, + DefaultValue = defaultExpression + }); + table.PrimaryKey.Add("id"); + + Entity entity = GraphQLTestHelpers.GenerateEntityWithSingularPlural(entityName, entityName.Pluralize()); + IEnumerable roles = new[] { "anonymous" }; + return SchemaConverter.GenerateObjectTypeDefinitionForDatabaseObject( + entityName, + new DatabaseTable { TableDefinition = table }, + entity, + new(new Dictionary { { entityName, entity } }), + rolesAllowedForEntity: roles, + rolesAllowedForFields: table.Columns.Keys.ToDictionary(column => column, _ => roles), + databaseType: databaseType); + } + + private static ISchemaBuilder CreateSchemaBuilderForCreateInputs(DocumentNode mutationRoot, string mutationName = "createFoo") + { + FieldDefinitionNode mutation = GetMutationNode(mutationRoot).Fields.Single(field => field.Name.Value == mutationName); + DocumentNode inputs = new(mutationRoot.Definitions.OfType().ToArray()); + + // Use the generated input types and argument shape while keeping database resolvers out of this schema. + return SchemaBuilder.New() + .AddDocumentFromString(inputs.ToString()) + .AddDocumentFromString($"type Query {{ inspect({string.Join(", ", mutation.Arguments.Select(argument => $"{argument.Name}: {argument.Type}"))}): Boolean }}") + .AddDirectiveType() + .AddType() + .AddType() + .AddType(); } public static ObjectTypeDefinitionNode GetMutationNode(DocumentNode mutationRoot) diff --git a/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs b/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs index f5f8641cc2..ee35167962 100644 --- a/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs @@ -99,7 +99,11 @@ public void AddGraphQLFields_FragmentSpreadWithoutContextThrows() SqlQueryStructure structure = CreateStructure(); ISelectionNode[] selections = { - new FragmentSpreadNode(null, new NameNode("BookFields"), System.Array.Empty()) + new FragmentSpreadNode( + location: null, + name: new NameNode("BookFields"), + arguments: System.Array.Empty(), + directives: System.Array.Empty()) }; TargetInvocationException exception = Assert.ThrowsException(() => From d704912e6bbaf871cb161fe3b7333fbcd3162e52 Mon Sep 17 00:00:00 2001 From: Mohit G Date: Tue, 22 Sep 2026 11:20:13 +0530 Subject: [PATCH 2/2] Add regression coverage for GraphQL variable inputs --- .../GraphQLBuilder/MutationBuilderTests.cs | 180 ++++++++++++++---- 1 file changed, 144 insertions(+), 36 deletions(-) diff --git a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs index 9a013f9056..6dd75f7bb8 100644 --- a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs @@ -1041,6 +1041,7 @@ type Foo @model(name:""Foo"") {{ [DataTestMethod] [DataRow(DatabaseType.MSSQL, typeof(int), "((1))", "7", false)] + [DataRow(DatabaseType.MSSQL, typeof(int), "((42))", "7", false)] [DataRow(DatabaseType.MSSQL, typeof(int), "(rand())", "7", false)] [DataRow(DatabaseType.MSSQL, typeof(double), "((0))", "1.25", false)] [DataRow(DatabaseType.MSSQL, typeof(decimal), "((1.5))", "1.25", false)] @@ -1081,36 +1082,45 @@ public async Task SqlDefaultExpressionsCreateValidInputSchema( Assert.IsInstanceOfType>(parameters["item"]); IEnumerable fields = (IEnumerable)parameters["item"]; InputObjectType inputType = ExecutionHelper.InputObjectTypeFromIInputField(context.Selection.Field.Arguments["item"]); - capturedInput = fields.ToDictionary( - field => field.Name.Value, - field => ExecutionHelper.ExtractValueFromIValueNode( - field.Value, inputType.Fields[field.Name.Value], context.Variables)); + capturedInput = ExtractInputFields(fields, inputType, context.Variables); return true; }) .Create() .MakeExecutable(); - OperationResult omitted = (await executor.ExecuteAsync("{ inspect(item: { id: 1 }) }")).ExpectOperationResult(); - Assert.AreEqual(0, omitted.Errors.Count, string.Join(" | ", omitted.Errors.Select(error => error.ToString()))); - Assert.IsNotNull(capturedInput); - Assert.IsFalse(capturedInput.ContainsKey("defaulted"), - "An omitted SQL column must remain absent so the database, not GraphQL, evaluates its default."); - - OperationResult explicitNull = (await executor.ExecuteAsync("{ inspect(item: { id: 1, defaulted: null }) }")).ExpectOperationResult(); - Assert.AreEqual(0, explicitNull.Errors.Count, string.Join(" | ", explicitNull.Errors.Select(error => error.ToString()))); - Assert.IsTrue(capturedInput.ContainsKey("defaulted"), "Explicit null must remain distinct from omission."); - Assert.IsNull(capturedInput["defaulted"], "A SQL default must not replace an explicitly supplied null."); - - OperationResult supplied = (await executor.ExecuteAsync( - $"{{ inspect(item: {{ id: 1, defaulted: {suppliedLiteral} }}) }}")).ExpectOperationResult(); - Assert.AreEqual(0, supplied.Errors.Count, string.Join(" | ", supplied.Errors.Select(error => error.ToString()))); - Assert.IsTrue(capturedInput.ContainsKey("defaulted")); - Assert.IsNotNull(capturedInput["defaulted"], "An explicit value must survive input coercion."); - - capturedInput = null; - OperationResult missingRequired = (await executor.ExecuteAsync("{ inspect(item: {}) }")).ExpectOperationResult(); - Assert.IsTrue(missingRequired.Errors.Count > 0, "A non-null column without a default must still be required."); - Assert.IsNull(capturedInput, "Invalid input must be rejected before invoking a resolver."); + foreach (bool useVariables in new[] { false, true }) + { + capturedInput = null; + OperationResult omitted = await ExecuteCreateInputAsync( + executor, "item", "{ id: 1 }", """{ "id": 1 }""", useVariables); + Assert.AreEqual(0, omitted.Errors.Count, string.Join(" | ", omitted.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.IsFalse(capturedInput.ContainsKey("defaulted"), + "An omitted SQL column must remain absent so the database, not GraphQL, evaluates its default."); + + capturedInput = null; + OperationResult explicitNull = await ExecuteCreateInputAsync( + executor, "item", "{ id: 1, defaulted: null }", """{ "id": 1, "defaulted": null }""", useVariables); + Assert.AreEqual(0, explicitNull.Errors.Count, string.Join(" | ", explicitNull.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.IsTrue(capturedInput.ContainsKey("defaulted"), "Explicit null must remain distinct from omission."); + Assert.IsNull(capturedInput["defaulted"], "A SQL default must not replace an explicitly supplied null."); + + capturedInput = null; + OperationResult supplied = await ExecuteCreateInputAsync( + executor, "item", $"{{ id: 1, defaulted: {suppliedLiteral} }}", + $$"""{ "id": 1, "defaulted": {{suppliedLiteral}} }""", useVariables); + Assert.AreEqual(0, supplied.Errors.Count, string.Join(" | ", supplied.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.IsTrue(capturedInput.ContainsKey("defaulted")); + Assert.AreEqual(suppliedLiteral, JsonSerializer.Serialize(capturedInput["defaulted"]), + "An explicitly supplied value must survive input coercion unchanged."); + + capturedInput = null; + OperationResult missingRequired = await ExecuteCreateInputAsync(executor, "item", "{}", "{}", useVariables); + Assert.IsTrue(missingRequired.Errors.Count > 0, "A non-null column without a default must still be required."); + Assert.IsNull(capturedInput, "Invalid input must be rejected before invoking a resolver."); + } InputObjectTypeDefinitionNode input = mutationRoot.Definitions.OfType() .Single(node => node.Name.Value == "CreateFooInput"); @@ -1120,11 +1130,13 @@ public async Task SqlDefaultExpressionsCreateValidInputSchema( } [DataTestMethod] - [DataRow(false)] - [DataRow(true)] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] [TestCategory("Mutation Builder - Create")] [TestCategory("GraphQL Schema Builder")] - public async Task SqlDefaultsRemainOptionalForMultipleAndLinkingCreate(bool createMultiple) + public async Task SqlDefaultsRemainOptionalForMultipleAndLinkingCreate(bool createMultiple, bool useVariables) { DocumentNode relationshipTypes = Utf8GraphQLParser.Parse(""" type Foo @model(name: "Foo") { @@ -1163,25 +1175,89 @@ type Bar @model(name: "Bar") { string mutationName = createMultiple ? "createFoos" : "createFoo"; string argumentName = createMultiple ? MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME : MutationBuilder.ITEM_INPUT_ARGUMENT_NAME; + Dictionary capturedInput = null; + Dictionary capturedLinkingInput = null; IRequestExecutor executor = CreateSchemaBuilderForCreateInputs(mutationRoot, mutationName) - .AddResolver("Query", "inspect", _ => true) + .AddResolver("Query", "inspect", context => + { + IDictionary parameters = ExecutionHelper.GetParametersFromSchemaAndQueryFields( + context.Selection.Field, context.Selection.RequireFieldNode(), context.Variables); + IEnumerable fields; + if (createMultiple) + { + Assert.IsInstanceOfType>(parameters[argumentName]); + IValueNode inputItem = ((IEnumerable)parameters[argumentName]).Single(); + Assert.IsInstanceOfType(inputItem); + fields = ((ObjectValueNode)inputItem).Fields; + } + else + { + Assert.IsInstanceOfType>(parameters[argumentName]); + fields = (IEnumerable)parameters[argumentName]; + } + + InputObjectType inputType = ExecutionHelper.InputObjectTypeFromIInputField(context.Selection.Field.Arguments[argumentName]); + capturedInput = ExtractInputFields(fields, inputType, context.Variables); + + Assert.IsInstanceOfType>(capturedInput["bars"]); + IValueNode linkingItem = ((IEnumerable)capturedInput["bars"]).Single(); + Assert.IsInstanceOfType(linkingItem); + InputObjectType linkingInputType = ExecutionHelper.InputObjectTypeFromIInputField(inputType.Fields["bars"]); + capturedLinkingInput = ExtractInputFields(((ObjectValueNode)linkingItem).Fields, linkingInputType, context.Variables); + return true; + }) .Create() .MakeExecutable(); string item = "{ id: 1, bars: [{ id: 2 }] }"; - string input = createMultiple ? $"[{item}]" : item; - OperationResult omitted = (await executor.ExecuteAsync($"{{ inspect({argumentName}: {input}) }}")).ExpectOperationResult(); + string variableItem = """{ "id": 1, "bars": [{ "id": 2 }] }"""; + OperationResult omitted = await ExecuteCreateInputAsync( + executor, argumentName, createMultiple ? $"[{item}]" : item, + createMultiple ? $"[{variableItem}]" : variableItem, useVariables); Assert.AreEqual(0, omitted.Errors.Count, string.Join(" | ", omitted.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.IsNotNull(capturedLinkingInput); + Assert.IsFalse(capturedInput.ContainsKey("defaulted"), "An omitted SQL column must remain absent."); + Assert.IsFalse(capturedLinkingInput.ContainsKey("royalty_percentage"), "An omitted linking column must remain absent."); - item = "{ id: 1, bars: [{ id: 2, royalty_percentage: null }] }"; - input = createMultiple ? $"[{item}]" : item; - OperationResult explicitNull = (await executor.ExecuteAsync($"{{ inspect({argumentName}: {input}) }}")).ExpectOperationResult(); + capturedInput = null; + capturedLinkingInput = null; + item = "{ id: 1, defaulted: null, bars: [{ id: 2, royalty_percentage: null }] }"; + variableItem = """{ "id": 1, "defaulted": null, "bars": [{ "id": 2, "royalty_percentage": null }] }"""; + OperationResult explicitNull = await ExecuteCreateInputAsync( + executor, argumentName, createMultiple ? $"[{item}]" : item, + createMultiple ? $"[{variableItem}]" : variableItem, useVariables); Assert.AreEqual(0, explicitNull.Errors.Count, string.Join(" | ", explicitNull.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.IsNotNull(capturedLinkingInput); + Assert.IsTrue(capturedInput.ContainsKey("defaulted"), "Explicit null must remain distinct from omission."); + Assert.IsNull(capturedInput["defaulted"]); + Assert.IsTrue(capturedLinkingInput.ContainsKey("royalty_percentage"), "Explicit null must remain present on a linking input."); + Assert.IsNull(capturedLinkingInput["royalty_percentage"]); + capturedInput = null; + capturedLinkingInput = null; + item = "{ id: 1, defaulted: 7, bars: [{ id: 2, royalty_percentage: 0.75 }] }"; + variableItem = """{ "id": 1, "defaulted": 7, "bars": [{ "id": 2, "royalty_percentage": 0.75 }] }"""; + OperationResult supplied = await ExecuteCreateInputAsync( + executor, argumentName, createMultiple ? $"[{item}]" : item, + createMultiple ? $"[{variableItem}]" : variableItem, useVariables); + Assert.AreEqual(0, supplied.Errors.Count, string.Join(" | ", supplied.Errors.Select(error => error.ToString()))); + Assert.IsNotNull(capturedInput); + Assert.IsNotNull(capturedLinkingInput); + Assert.AreEqual(7, capturedInput["defaulted"], "An explicit value must not be replaced by a SQL default."); + Assert.AreEqual(0.75, capturedLinkingInput["royalty_percentage"], "An explicit linking value must remain unchanged."); + + capturedInput = null; + capturedLinkingInput = null; item = "{ id: 1, bars: [{}] }"; - input = createMultiple ? $"[{item}]" : item; - OperationResult missingRequired = (await executor.ExecuteAsync($"{{ inspect({argumentName}: {input}) }}")).ExpectOperationResult(); + variableItem = """{ "id": 1, "bars": [{}] }"""; + OperationResult missingRequired = await ExecuteCreateInputAsync( + executor, argumentName, createMultiple ? $"[{item}]" : item, + createMultiple ? $"[{variableItem}]" : variableItem, useVariables); Assert.IsTrue(missingRequired.Errors.Count > 0, "Non-defaulted fields on the linking input must remain required."); + Assert.IsNull(capturedInput, "Invalid input must be rejected before invoking a resolver."); + Assert.IsNull(capturedLinkingInput); InputObjectTypeDefinitionNode linkingInput = mutationRoot.Definitions.OfType() .Single(node => node.Name.Value == CreateMutationBuilder.GenerateInputTypeName(linkingTypeName).Value); @@ -1191,6 +1267,38 @@ type Bar @model(name: "Bar") { Assert.AreEqual("Float", defaultedField.Type.NamedType().Name.Value); } + private static async Task ExecuteCreateInputAsync( + IRequestExecutor executor, + string argumentName, + string literalInput, + string variableInput, + bool useVariables) + { + OperationRequestBuilder request = OperationRequestBuilder.New(); + if (useVariables) + { + IInputType inputType = executor.Schema.QueryType.Fields["inspect"].Arguments[argumentName].Type; + request.SetDocument($"query (${argumentName}: {inputType}) {{ inspect({argumentName}: ${argumentName}) }}") + .SetVariableValues($"{{ \"{argumentName}\": {variableInput} }}"); + } + else + { + request.SetDocument($"{{ inspect({argumentName}: {literalInput}) }}"); + } + + return (await executor.ExecuteAsync(request.Build())).ExpectOperationResult(); + } + + private static Dictionary ExtractInputFields( + IEnumerable fields, + InputObjectType inputType, + IVariableValueCollection variables) + { + return fields.ToDictionary( + field => field.Name.Value, + field => ExecutionHelper.ExtractValueFromIValueNode(field.Value, inputType.Fields[field.Name.Value], variables)); + } + private static ObjectTypeDefinitionNode GenerateSqlObjectTypeWithDefault( Type systemType, string defaultExpression,