diff --git a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs index 3c7d9fc42e..795952da1c 100644 --- a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs @@ -187,26 +187,48 @@ private void ParseSchemaGraphQLFieldsForJoins() /// 4. Check if we get previous entity with join information, if yes append it to the current entity also /// 5. Recursively call this function, to process the schema /// - /// - /// - /// - /// indicates the parent entity for which we are processing the schema. - private void ProcessSchema(IReadOnlyList fields, + /// All the fields of an entity + /// Schema Documents, useful to get fields information of an entity + /// Generated path of an entity + /// Counter used to generate table alias + /// indicates the parent entity for which we are processing the schema. + /// It is useful to get the JOIN statement information and create further new statements + /// Keeps a track of the path in an entity, to detect circular reference + /// It detects the circular reference in the schema while processing the schema and throws + private void ProcessSchema( + IReadOnlyList fields, Dictionary schemaDocument, string currentPath, IncrementingInteger tableCounter, - EntityDbPolicyCosmosModel? previousEntity = null) + EntityDbPolicyCosmosModel? parentEntity = null, + HashSet? visitedEntities = null) { // Traverse the fields and add them to the path foreach (FieldDefinitionNode field in fields) { - string entityType = field.Type.NamedType().Name.Value; - // If the entity is not in the runtime config, skip it - if (!_runtimeConfig.Entities.ContainsKey(entityType)) + // Create a tracker to keep track of visited entities to detect circular references + HashSet trackerForFields = new(); + if (visitedEntities is not null) + { + trackerForFields = visitedEntities; + } + + // If the entity is build-in type, do not go further to check circular reference + if (GraphQLUtils.IsBuiltInType(field.Type)) { continue; } + string entityType = field.Type.NamedType().Name.Value; + // If the entity is already visited, then it is a circular reference + if (!trackerForFields.Add(entityType)) + { + throw new DataApiBuilderException( + message: $"Circular reference detected in the provided GraphQL schema for entity '{entityType}'.", + statusCode: System.Net.HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + string? alias = null; bool isArrayType = field.Type is ListTypeNode; if (isArrayType) @@ -235,15 +257,15 @@ private void ProcessSchema(IReadOnlyList fields, }); } - if (previousEntity is not null) + if (parentEntity is not null) { if (string.IsNullOrEmpty(currentEntity.JoinStatement)) { - currentEntity.JoinStatement = previousEntity.JoinStatement; + currentEntity.JoinStatement = parentEntity.JoinStatement; } else { - currentEntity.JoinStatement = previousEntity.JoinStatement + " JOIN " + currentEntity.JoinStatement; + currentEntity.JoinStatement = parentEntity.JoinStatement + " JOIN " + currentEntity.JoinStatement; } } @@ -253,7 +275,8 @@ private void ProcessSchema(IReadOnlyList fields, schemaDocument: schemaDocument, currentPath: isArrayType ? $"{alias}" : $"{currentPath}.{field.Name.Value}", tableCounter: tableCounter, - previousEntity: isArrayType ? currentEntity : null); + parentEntity: isArrayType ? currentEntity : null, + visitedEntities: trackerForFields); } } diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index cc1469e3e2..d0c3a85ff3 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -425,6 +425,48 @@ public class ConfigurationTests } }"; + internal const string GRAPHQL_SCHEMA_WITH_CYCLE_ARRAY = @" +type Character { + id : ID, + name : String, + moons: [Moon], +} + +type Planet @model(name:""Planet"") { + id : ID!, + name : String, + character: Character +} + +type Moon { + id : ID, + name : String, + details : String, + character: Character +} +"; + + internal const string GRAPHQL_SCHEMA_WITH_CYCLE_OBJECT = @" +type Character { + id : ID, + name : String, + moons: Moon, +} + +type Planet @model(name:""Planet"") { + id : ID!, + name : String, + character: Character +} + +type Moon { + id : ID, + name : String, + details : String, + character: Character +} +"; + [TestCleanup] public void CleanupAfterEachTest() { @@ -2938,6 +2980,41 @@ public async Task TestEngineSupportViewsWithoutKeyFieldsInConfigForMsSQL() } } + /// + /// In CosmosDB NoSQL, we store data in the form of JSON. Practically, JSON can be very complex. + /// But DAB doesn't support JSON with circular references e.g if 'Character.Moon' is a valid JSON Path, then + /// 'Moon.Character' should not be there, DAB would throw an exception during the load itself. + /// + /// + [TestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_OBJECT, DisplayName = "When Circular Reference is there with Object type (i.e. 'Moon' in 'Character' Entity")] + [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_ARRAY, DisplayName = "When Circular Reference is there with Array type (i.e. '[Moon]' in 'Character' Entity")] + public void ValidateGraphQLSchemaForCircularReference(string schema) + { + // Read the base config from the file system + TestHelper.SetupDatabaseEnvironment(TestCategory.COSMOSDBNOSQL); + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + if (!baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig)) + { + throw new ApplicationException("Failed to load the default CosmosDB_NoSQL config and cannot continue with tests."); + } + + // Setup a mock file system, and use that one with the loader/provider for the config + MockFileSystem fileSystem = new(new Dictionary() + { + { @"../schema.gql", new MockFileData(schema) }, + { DEFAULT_CONFIG_FILE_NAME, new MockFileData(baseConfig.ToJson()) } + }); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); + + DataApiBuilderException exception = + Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, fileSystem)); + Assert.AreEqual("Circular reference detected in the provided GraphQL schema for entity 'Character'.", exception.Message); + Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode); + } + /// /// When you query, DAB loads schema and check for defined entities in the config file which get load during DAB initialization, and /// it fails during this check if entity is not defined in the config file. In this test case, we are testing the error message is appropriate.