diff --git a/Dockerfile b/Dockerfile index a1532beb1b..c8cde991ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,14 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-azurelinux3.0 AS build WORKDIR /src COPY [".", "./"] RUN dotnet build "./src/Service/Azure.DataApiBuilder.Service.csproj" -c Docker -o /out -r linux-x64 +RUN mkdir /package \ + && cp /src/src/Service/dab-config*.json /package/ \ + && find /src/src/Service/ -maxdepth 1 -name "*.gql" -exec cp {} /package/ \; FROM mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0 AS runtime COPY --from=build /out /App -# Add default dab-config.json to /App in the image -COPY --from=build /out/dab-config.json /App/dab-config.json +COPY --from=build /package /App WORKDIR /App ENV ASPNETCORE_URLS=http://+:5000 ENTRYPOINT ["dotnet", "Azure.DataApiBuilder.Service.dll"] diff --git a/src/Core/Models/GraphQLFilterParsers.cs b/src/Core/Models/GraphQLFilterParsers.cs index 6a97de9f04..db13953550 100644 --- a/src/Core/Models/GraphQLFilterParsers.cs +++ b/src/Core/Models/GraphQLFilterParsers.cs @@ -721,6 +721,77 @@ public static Predicate Parse( op = isNull ? PredicateOperation.IS : PredicateOperation.IS_NOT; value = GQLFilterParser.NullStringValue; break; + case "some": + op = PredicateOperation.ARRAY_SOME; + + if (value is not List elementFields || elementFields.Count == 0) + { + throw new DataApiBuilderException( + message: "Bad syntax: 'some' list filter requires at least one nested filter field.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + { + IInputValueDefinition elementFilterSchema = argumentObject.Fields["some"]; + Predicate nestedPredicate = Parse(ctx, elementFilterSchema, column, elementFields, processLiterals, false); + predicates.Push(new PredicateOperand(new Predicate( + new PredicateOperand(column), + op, + new PredicateOperand(nestedPredicate) + ))); + } + + continue; + case "none": + op = PredicateOperation.ARRAY_NONE; + + if (value is not List noneFields || noneFields.Count == 0) + { + throw new DataApiBuilderException( + message: "Bad syntax: 'none' list filter requires at least one nested filter field.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + { + IInputValueDefinition elementFilterSchema = argumentObject.Fields["none"]; + Predicate nestedPredicate = Parse(ctx, elementFilterSchema, column, noneFields, processLiterals, false); + predicates.Push(new PredicateOperand(new Predicate( + new PredicateOperand(column), + op, + new PredicateOperand(nestedPredicate) + ))); + } + + continue; + case "all": + op = PredicateOperation.ARRAY_ALL; + + if (value is not List allFields || allFields.Count == 0) + { + throw new DataApiBuilderException( + message: "Bad syntax: 'all' list filter requires at least one nested filter field.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + { + IInputValueDefinition elementFilterSchema = argumentObject.Fields["all"]; + Predicate nestedPredicate = Parse(ctx, elementFilterSchema, column, allFields, processLiterals, false); + predicates.Push(new PredicateOperand(new Predicate( + new PredicateOperand(column), + op, + new PredicateOperand(nestedPredicate) + ))); + } + + continue; + case "any": + processLiteral = false; + op = PredicateOperation.ARRAY_ANY; + value = (bool)value ? "true" : "false"; + break; default: throw new NotSupportedException($"Operation {name} on int type not supported."); } diff --git a/src/Core/Models/SqlQueryStructures.cs b/src/Core/Models/SqlQueryStructures.cs index f48186233f..b4e1c87d19 100644 --- a/src/Core/Models/SqlQueryStructures.cs +++ b/src/Core/Models/SqlQueryStructures.cs @@ -177,7 +177,8 @@ public enum PredicateOperation None, Equal, GreaterThan, LessThan, GreaterThanOrEqual, LessThanOrEqual, NotEqual, AND, OR, LIKE, NOT_LIKE, - IS, IS_NOT, EXISTS, ARRAY_CONTAINS, NOT_ARRAY_CONTAINS, IN + IS, IS_NOT, EXISTS, ARRAY_CONTAINS, NOT_ARRAY_CONTAINS, IN, + ARRAY_SOME, ARRAY_NONE, ARRAY_ALL, ARRAY_ANY } /// diff --git a/src/Core/Resolvers/CosmosQueryBuilder.cs b/src/Core/Resolvers/CosmosQueryBuilder.cs index e6f835429f..c3272c81f5 100644 --- a/src/Core/Resolvers/CosmosQueryBuilder.cs +++ b/src/Core/Resolvers/CosmosQueryBuilder.cs @@ -114,6 +114,8 @@ protected override string Build(PredicateOperation op) return "ARRAY_CONTAINS"; case PredicateOperation.NOT_ARRAY_CONTAINS: return "NOT ARRAY_CONTAINS"; + case PredicateOperation.IN: + return "IN"; default: throw new ArgumentException($"Cannot build unknown predicate operation {op}."); } @@ -137,6 +139,32 @@ protected override string Build(Predicate? predicate) { predicateString = $" {Build(predicate.Op)} ( {ResolveOperand(predicate.Left)}, {ResolveOperand(predicate.Right)})"; } + else if (predicate.Op == PredicateOperation.ARRAY_SOME || + predicate.Op == PredicateOperation.ARRAY_NONE || + predicate.Op == PredicateOperation.ARRAY_ALL) + { + string arrayField = ResolveOperand(predicate.Left); + string elementAlias = ArrayElementAlias(arrayField); + + Predicate elementPredicate = predicate.Right?.AsPredicate() + ?? throw new ArgumentException("Array element filter (some/none/all) requires a nested predicate."); + string elementPredicateStr = BuildArrayElementPredicate(elementPredicate, elementAlias); + + predicateString = predicate.Op switch + { + PredicateOperation.ARRAY_SOME => $" EXISTS(SELECT VALUE 1 FROM {elementAlias} IN {arrayField} WHERE {elementPredicateStr}) ", + PredicateOperation.ARRAY_NONE => $" NOT EXISTS(SELECT VALUE 1 FROM {elementAlias} IN {arrayField} WHERE {elementPredicateStr}) ", + // ARRAY_ALL: every element matches => no element exists that does not match. + _ => $" NOT EXISTS(SELECT VALUE 1 FROM {elementAlias} IN {arrayField} WHERE NOT ({elementPredicateStr})) " + }; + } + else if (predicate.Op == PredicateOperation.ARRAY_ANY) + { + string arrayField = ResolveOperand(predicate.Left); + predicateString = ResolveOperand(predicate.Right).Equals("true") + ? $" ARRAY_LENGTH({arrayField}) > 0 " + : $" (NOT IS_DEFINED({arrayField}) OR ARRAY_LENGTH({arrayField}) = 0) "; + } else if (ResolveOperand(predicate.Right).Equals(GQLFilterParser.NullStringValue)) { // For Binary predicates: @@ -163,6 +191,36 @@ protected override string Build(Predicate? predicate) } } + /// + /// Generates a deterministic iteration alias for an array element based on the array field path, + /// e.g. "c.tags" => "element_c_tags". + /// + private static string ArrayElementAlias(string arrayField) + => "element_" + arrayField.Replace(".", "_").Replace("\"", string.Empty); + + /// + /// Builds the predicate applied to each element of an array (used by some/none/all filters). + /// The element alias is bound directly to the array column reference while walking the predicate + /// tree, avoiding fragile string replacement on the rendered SQL. + /// + private string BuildArrayElementPredicate(Predicate predicate, string elementAlias) + { + // Leaf predicate: the left operand is the array column. The filter applies to the + // element value itself, so render the element alias in place of the column reference. + if (predicate.Left?.AsColumn() is not null) + { + string right = ResolveOperand(predicate.Right); + return right.Equals(GQLFilterParser.NullStringValue) + ? $"{Build(predicate.Op)} IS_NULL({elementAlias})" + : $"{elementAlias} {Build(predicate.Op)} {right}"; + } + + // Chain predicate (AND/OR): recurse into both operands. + string left = BuildArrayElementPredicate(predicate.Left!.AsPredicate()!, elementAlias); + string rightStr = BuildArrayElementPredicate(predicate.Right!.AsPredicate()!, elementAlias); + return $"({left} {Build(predicate.Op)} {rightStr})"; + } + /// /// Resolves the operand either as a column, another predicate, /// a SqlQueryStructure or returns it directly as string diff --git a/src/Core/Resolvers/CosmosQueryEngine.cs b/src/Core/Resolvers/CosmosQueryEngine.cs index de879040b0..f8fdc7ce39 100644 --- a/src/Core/Resolvers/CosmosQueryEngine.cs +++ b/src/Core/Resolvers/CosmosQueryEngine.cs @@ -11,6 +11,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.Cache; using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.GraphQLBuilder; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using Azure.DataApiBuilder.Service.Services; using HotChocolate.Language; @@ -34,6 +35,23 @@ public class CosmosQueryEngine : IQueryEngine private readonly RuntimeConfigProvider _runtimeConfigProvider; private readonly DabCacheService _cache; + // Maps relationship-field filter operators to their Cosmos SQL predicate templates ({0}=field, {1}=param). + private static readonly IReadOnlyDictionary _relationshipFilterTemplates = + new Dictionary(StringComparer.Ordinal) + { + ["eq"] = "c.{0} = {1}", + ["ne"] = "c.{0} != {1}", + ["neq"] = "c.{0} != {1}", + ["gt"] = "c.{0} > {1}", + ["gte"] = "c.{0} >= {1}", + ["lt"] = "c.{0} < {1}", + ["lte"] = "c.{0} <= {1}", + ["contains"] = "CONTAINS(c.{0}, {1})", + ["notContains"] = "NOT CONTAINS(c.{0}, {1})", + ["startsWith"] = "STARTSWITH(c.{0}, {1})", + ["endsWith"] = "ENDSWITH(c.{0}, {1})", + }; + /// /// Constructor /// @@ -64,7 +82,6 @@ public async Task> ExecuteAsync( IDictionary parameters, string dataSourceName) { - // TODO: add support for join query against another container // TODO: add support for TOP and Order-by push-down ISqlMetadataProvider metadataStoreProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); @@ -109,6 +126,26 @@ public async Task> ExecuteAsync( executeQueryResult = await ExecuteQueryAsync(structure, querySpec, queryRequestOptions, container, idValue, partitionKeyValue); } + // Resolve relationships for the single entity result + if (executeQueryResult != null) + { + // Check if this is a paginated result (connection type) + if (structure.IsPaginated && executeQueryResult[QueryBuilder.PAGINATION_FIELD_NAME] is JArray itemsArray) + { + // Resolve relationships for items in the pagination result + List itemsList = itemsArray.Cast().ToList(); + await ResolveRelationshipsAsync(context, itemsList, structure.EntityName, metadataStoreProvider); + // Items are modified in place, no need to update the array + } + else + { + // Single entity result + List resultsList = new() { executeQueryResult }; + await ResolveRelationshipsAsync(context, resultsList, structure.EntityName, metadataStoreProvider); + executeQueryResult = resultsList[0]; + } + } + JsonDocument response = executeQueryResult != null ? JsonDocument.Parse(executeQueryResult.ToString()) : null; return new Tuple(response, null); @@ -197,7 +234,6 @@ public async Task, IMetadata>> ExecuteListAsync( { // TODO: fixme we have multiple rounds of serialization/deserialization JsomDocument/JObject // TODO: add support for nesting - // TODO: add support for join query against another container // TODO: add support for TOP and Order-by push-down ISqlMetadataProvider metadataStoreProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); @@ -213,7 +249,7 @@ public async Task, IMetadata>> ExecuteListAsync( FeedIterator resultSetIterator = container.GetItemQueryIterator(querySpec); - List resultsAsList = new(); + List resultsAsList = new(); while (resultSetIterator.HasMoreResults) { FeedResponse nextPage = await resultSetIterator.ReadNextAsync(); @@ -221,11 +257,38 @@ public async Task, IMetadata>> ExecuteListAsync( while (enumerator.MoveNext()) { JObject item = enumerator.Current; - resultsAsList.Add(JsonDocument.Parse(item.ToString())); + resultsAsList.Add(item); } } - return new Tuple, IMetadata>(resultsAsList, null); + // Resolve relationships (cross-container queries) + await ResolveRelationshipsAsync(context, resultsAsList, structure.EntityName, metadataStoreProvider); + + // Convert JObject list to JsonDocument list + // Serialize the entire array at once to properly preserve nested type information + JArray jArray = new(resultsAsList); + using (MemoryStream ms = new()) + using (StreamWriter writer = new(ms, System.Text.Encoding.UTF8, 1024, leaveOpen: true)) + using (Newtonsoft.Json.JsonTextWriter jsonWriter = new(writer)) + { + jArray.WriteTo(jsonWriter); + jsonWriter.Flush(); + writer.Flush(); + ms.Position = 0; + + // Deserialize as array of JsonDocuments + JsonDocument arrayDoc = JsonDocument.Parse(ms); + List jsonDocumentList = new(); + foreach (JsonElement element in arrayDoc.RootElement.EnumerateArray()) + { + // Clone each element as its own JsonDocument + jsonDocumentList.Add(JsonDocument.Parse(element.GetRawText())); + } + + arrayDoc.Dispose(); + + return new Tuple, IMetadata>(jsonDocumentList, null); + } } /// @@ -272,7 +335,32 @@ public object ResolveList(JsonElement array, ObjectField fieldSchema, ref IMetad return JsonSerializer.Deserialize>(array); } - return JsonSerializer.Deserialize(array, fieldSchema.RuntimeType); + // For primitive arrays, manually enumerate and extract values to avoid JsonElement wrappers + List result = new(); + foreach (JsonElement element in array.EnumerateArray()) + { + result.Add(UnwrapJsonElement(element)); + } + + return result; + } + + /// + /// Unwraps a JsonElement to its underlying primitive value + /// + private static object UnwrapJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt32(out int intVal) ? intVal : + element.TryGetInt64(out long longVal) ? longVal : + element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element // Return as-is for objects/arrays + }; } /// @@ -464,5 +552,454 @@ private static async Task GetPartitionKeyPath(Container container, ISqlM byte[] base64EncodedBytes = Convert.FromBase64String(base64EncodedData); return Encoding.UTF8.GetString(base64EncodedBytes); } + + private static object? GetScalarValue(IValueNode valueNode) + { + return valueNode switch + { + StringValueNode stringValue => stringValue.Value, + IntValueNode intValue => int.Parse(intValue.Value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture), + FloatValueNode floatValue => double.Parse(floatValue.Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture), + BooleanValueNode boolValue => boolValue.Value, + NullValueNode => null, + _ => valueNode.ToString() + }; + } + + private static void CollectRequestedFields( + SelectionSetNode selectionSet, + DocumentNode document, + Dictionary requestedFields) + { + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode fieldNode: + // Only add if not already present (fragment fields can be duplicated) + if (!requestedFields.ContainsKey(fieldNode.Name.Value)) + { + requestedFields[fieldNode.Name.Value] = fieldNode; + } + + break; + case FragmentSpreadNode fragmentSpread: + var fragment = document.Definitions + .OfType() + .FirstOrDefault(f => f.Name.Value == fragmentSpread.Name.Value); + if (fragment != null) + { + CollectRequestedFields(fragment.SelectionSet, document, requestedFields); + } + + break; + case InlineFragmentNode inlineFragment: + CollectRequestedFields(inlineFragment.SelectionSet, document, requestedFields); + break; + } + } + } + + /// + /// Resolves relationships by executing cross-container queries for related entities. + /// Extracts the selection set from the middleware context and delegates to the internal method. + /// + private async Task ResolveRelationshipsAsync( + IMiddlewareContext context, + List results, + string entityName, + ISqlMetadataProvider metadataProvider) + { + if (results.Count == 0) + { + return; + } + + // Get the selection set to find which relationship fields are requested + SelectionSetNode? selectionSet = context.Selection.RequireFieldNode().SelectionSet; + if (selectionSet == null) + { + return; + } + + // For paginated queries, the selection set contains "items" which contains the actual fields + // We need to look inside the "items" field to find relationship fields + FieldNode? itemsField = selectionSet.Selections + .OfType() + .FirstOrDefault(f => f.Name.Value == QueryBuilder.PAGINATION_FIELD_NAME); + + if (itemsField?.SelectionSet != null) + { + selectionSet = itemsField.SelectionSet; + } + + await ResolveRelationshipsInternalAsync(selectionSet, context.Operation.Document, results, entityName, metadataProvider); + } + + /// + /// Recursively resolves relationships by executing cross-container queries for related entities. + /// Mutates the input results list to include related entity data, including nested relationships. + /// + private async Task ResolveRelationshipsInternalAsync( + SelectionSetNode selectionSet, + DocumentNode document, + List results, + string entityName, + ISqlMetadataProvider metadataProvider) + { + if (results.Count == 0) + { + return; + } + + // Get entity configuration + RuntimeConfig config = _runtimeConfigProvider.GetConfig(); + if (!config.Entities.TryGetValue(entityName, out Entity? entity)) + { + return; + } + + Dictionary requestedFields = new(); + CollectRequestedFields(selectionSet, document, requestedFields); + + // 1. Resolve configured relationships as cross-container joins. + if (entity.Relationships is not null) + { + foreach ((string fieldName, FieldNode fieldNode) in requestedFields) + { + if (entity.Relationships.TryGetValue(fieldName, out EntityRelationship? relationship)) + { + await ResolveRelationshipFieldAsync( + fieldNode, fieldName, relationship, entityName, metadataProvider, config, document, results); + } + } + } + + // 2. Descend into nested sub-objects that map to configured entities with relationships. + await ResolveNestedEntityRelationshipsAsync(requestedFields, entity, config, document, results); + } + + /// + /// Resolves a single configured relationship field by querying the target container for the + /// related rows and grafting them onto each source row according to the relationship cardinality. + /// + private async Task ResolveRelationshipFieldAsync( + FieldNode fieldNode, + string fieldName, + EntityRelationship relationship, + string entityName, + ISqlMetadataProvider metadataProvider, + RuntimeConfig config, + DocumentNode document, + List results) + { + string targetEntityName = relationship.TargetEntity.Split('.').Last(); + if (!config.Entities.ContainsKey(targetEntityName)) + { + return; + } + + // The target entity may live in a different data source (and Cosmos account). + string targetDataSourceName = config.GetDataSourceNameFromEntityName(targetEntityName); + if (!_clientProvider.Clients.TryGetValue(targetDataSourceName, out CosmosClient? targetClient) || targetClient is null) + { + return; + } + + ISqlMetadataProvider targetMetadataProvider = _metadataProviderFactory.GetMetadataProvider(targetDataSourceName); + + HashSet sourceValues = CollectSourceFieldValues(results, relationship, entityName, metadataProvider); + if (sourceValues.Count == 0) + { + return; + } + + string? targetField = relationship.TargetFields?.FirstOrDefault(); + if (string.IsNullOrEmpty(targetField)) + { + return; + } + + if (!targetMetadataProvider.TryGetExposedColumnName(targetEntityName, targetField, out string? exposedTargetField)) + { + exposedTargetField = targetField; + } + + Container container = targetClient + .GetDatabase(targetMetadataProvider.GetSchemaName(targetEntityName)) + .GetContainer(targetMetadataProvider.GetDatabaseObjectName(targetEntityName)); + + QueryDefinition queryDef = BuildRelatedEntitiesQuery( + exposedTargetField, sourceValues, fieldNode, targetEntityName, targetMetadataProvider); + Dictionary> relatedEntitiesByKey = + await ExecuteAndGroupByFieldAsync(container, queryDef, exposedTargetField); + + AssignRelatedEntities(results, relationship, fieldName, entityName, metadataProvider, relatedEntitiesByKey); + + // Recurse into the related rows to resolve their own relationships. + // JObject is a reference type, so these mutations are reflected in the already-assigned fields. + if (fieldNode.SelectionSet is not null && relatedEntitiesByKey.Count > 0) + { + List allRelated = relatedEntitiesByKey.Values.SelectMany(v => v).ToList(); + await ResolveRelationshipsInternalAsync(fieldNode.SelectionSet, document, allRelated, targetEntityName, targetMetadataProvider); + } + } + + /// + /// Resolves relationships defined on configured entities that appear as nested sub-objects of the + /// current results (e.g. a "general" sub-document whose "General" entity declares its own relationships). + /// + private async Task ResolveNestedEntityRelationshipsAsync( + Dictionary requestedFields, + Entity entity, + RuntimeConfig config, + DocumentNode document, + List results) + { + foreach ((string fieldName, FieldNode fieldNode) in requestedFields) + { + // Skip fields already handled as top-level relationships or that have no sub-selection. + if ((entity.Relationships is not null && entity.Relationships.ContainsKey(fieldName)) + || fieldNode.SelectionSet is null) + { + continue; + } + + string? matchedEntityName = FindEntityWithRelationshipsBySingularName(config, fieldName); + if (matchedEntityName is null) + { + continue; + } + + List nestedObjects = ExtractNestedObjects(results, fieldName); + if (nestedObjects.Count == 0) + { + continue; + } + + string nestedDataSourceName = config.GetDataSourceNameFromEntityName(matchedEntityName); + ISqlMetadataProvider nestedMetadataProvider = _metadataProviderFactory.GetMetadataProvider(nestedDataSourceName); + await ResolveRelationshipsInternalAsync(fieldNode.SelectionSet, document, nestedObjects, matchedEntityName, nestedMetadataProvider); + } + } + + /// + /// Collects the distinct values of the relationship's (first) source field across the given rows, + /// using the exposed (GraphQL) name of the backing column. + /// + private static HashSet CollectSourceFieldValues( + List results, + EntityRelationship relationship, + string entityName, + ISqlMetadataProvider metadataProvider) + { + HashSet values = new(); + string? sourceField = relationship.SourceFields?.FirstOrDefault(); + if (string.IsNullOrEmpty(sourceField)) + { + return values; + } + + if (!metadataProvider.TryGetExposedColumnName(entityName, sourceField, out string? exposedSourceField)) + { + exposedSourceField = sourceField; + } + + foreach (JObject result in results) + { + if (result.TryGetValue(exposedSourceField, out JToken? sourceValue) && sourceValue is not null) + { + values.Add(sourceValue.ToString()); + } + } + + return values; + } + + /// + /// Builds the Cosmos query that fetches the related rows: a parameterized IN clause over the + /// target field, plus any operator filters supplied on the relationship field argument. + /// + private static QueryDefinition BuildRelatedEntitiesQuery( + string exposedTargetField, + IReadOnlyCollection sourceValues, + FieldNode relationshipField, + string targetEntityName, + ISqlMetadataProvider targetMetadataProvider) + { + List> parameters = new(); + StringBuilder queryText = new($"SELECT * FROM c WHERE c.{exposedTargetField} IN ("); + + int index = 0; + foreach (string value in sourceValues) + { + string paramName = $"@value{index}"; + queryText.Append(index == 0 ? paramName : $", {paramName}"); + parameters.Add(new(paramName, value)); + index++; + } + + queryText.Append(')'); + + // Apply optional operator filters declared on the relationship field. + ArgumentNode? filterArg = relationshipField.Arguments + .FirstOrDefault(arg => arg.Name.Value == QueryBuilder.FILTER_FIELD_NAME); + if (filterArg?.Value is ObjectValueNode filterObject) + { + foreach (ObjectFieldNode filterField in filterObject.Fields) + { + if (filterField.Value is not ObjectValueNode operatorObject) + { + continue; + } + + if (!targetMetadataProvider.TryGetExposedColumnName(targetEntityName, filterField.Name.Value, out string? exposedFieldName)) + { + exposedFieldName = filterField.Name.Value; + } + + foreach (ObjectFieldNode operatorField in operatorObject.Fields) + { + if (!_relationshipFilterTemplates.TryGetValue(operatorField.Name.Value, out string? template)) + { + continue; + } + + string paramName = $"@filter{parameters.Count}"; + queryText.Append(" AND ").AppendFormat(template, exposedFieldName, paramName); + parameters.Add(new(paramName, GetScalarValue(operatorField.Value))); + } + } + } + + QueryDefinition queryDef = new(queryText.ToString()); + foreach ((string name, object? value) in parameters) + { + queryDef = queryDef.WithParameter(name, value); + } + + return queryDef; + } + + /// + /// Executes the related-entities query and groups the returned rows by the value of the target field. + /// + private static async Task>> ExecuteAndGroupByFieldAsync( + Container container, + QueryDefinition queryDef, + string exposedTargetField) + { + Dictionary> grouped = new(); + using FeedIterator iterator = container.GetItemQueryIterator(queryDef); + while (iterator.HasMoreResults) + { + FeedResponse page = await iterator.ReadNextAsync(); + foreach (JObject item in page) + { + if (item.TryGetValue(exposedTargetField, out JToken? targetValue) && targetValue is not null) + { + string key = targetValue.ToString(); + if (!grouped.TryGetValue(key, out List? bucket)) + { + bucket = new(); + grouped[key] = bucket; + } + + bucket.Add(item); + } + } + } + + return grouped; + } + + /// + /// Grafts the grouped related rows onto each source row. For Cardinality.One the related row is + /// assigned directly; otherwise a pagination connection object wrapping the related rows is assigned. + /// + private static void AssignRelatedEntities( + List results, + EntityRelationship relationship, + string fieldName, + string entityName, + ISqlMetadataProvider metadataProvider, + Dictionary> relatedEntitiesByKey) + { + string? sourceField = relationship.SourceFields?.FirstOrDefault(); + if (string.IsNullOrEmpty(sourceField)) + { + return; + } + + if (!metadataProvider.TryGetExposedColumnName(entityName, sourceField, out string? exposedSourceField)) + { + exposedSourceField = sourceField; + } + + foreach (JObject result in results) + { + if (!result.TryGetValue(exposedSourceField, out JToken? sourceValue) || sourceValue is null) + { + continue; + } + + relatedEntitiesByKey.TryGetValue(sourceValue.ToString(), out List? relatedEntities); + + if (relationship.Cardinality == Cardinality.One) + { + result[fieldName] = relatedEntities is { Count: > 0 } ? relatedEntities[0] : null; + } + else + { + result[fieldName] = new JObject( + new JProperty(QueryBuilder.PAGINATION_TOKEN_FIELD_NAME, (string?)null), + new JProperty(QueryBuilder.HAS_NEXT_PAGE_FIELD_NAME, false), + new JProperty(QueryBuilder.PAGINATION_FIELD_NAME, relatedEntities is null ? new JArray() : new JArray(relatedEntities))); + } + } + } + + /// + /// Finds a configured entity whose GraphQL singular name matches the field and which declares relationships. + /// + private static string? FindEntityWithRelationshipsBySingularName(RuntimeConfig config, string fieldName) + { + foreach ((string name, Entity candidate) in config.Entities) + { + if (string.Equals(candidate.GraphQL.Singular, fieldName, StringComparison.OrdinalIgnoreCase) + && candidate.Relationships is { Count: > 0 }) + { + return name; + } + } + + return null; + } + + /// + /// Extracts nested JObject values for a field from the given rows, flattening arrays of objects. + /// + private static List ExtractNestedObjects(List results, string fieldName) + { + List nestedObjects = new(); + foreach (JObject result in results) + { + if (!result.TryGetValue(fieldName, out JToken? nestedValue)) + { + continue; + } + + if (nestedValue is JObject nestedObj) + { + nestedObjects.Add(nestedObj); + } + else if (nestedValue is JArray nestedArray) + { + nestedObjects.AddRange(nestedArray.OfType()); + } + } + + return nestedObjects; + } } } diff --git a/src/Core/Resolvers/CosmosQueryStructure.cs b/src/Core/Resolvers/CosmosQueryStructure.cs index 29c435d955..487c2cfb8a 100644 --- a/src/Core/Resolvers/CosmosQueryStructure.cs +++ b/src/Core/Resolvers/CosmosQueryStructure.cs @@ -161,6 +161,31 @@ private void Init(IDictionary queryParams) (CosmosSqlMetadataProvider)MetadataProvider); } + // Ensure source fields from relationships are included in the query + // even if not explicitly requested, so they're available for relationship resolution + RuntimeConfigProvider.TryGetConfig(out RuntimeConfig? config); + if (config != null && config.Entities.TryGetValue(EntityName, out Entity? entity) && entity.Relationships != null) + { + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships) + { + if (relationship.SourceFields != null) + { + foreach (string sourceField in relationship.SourceFields) + { + // Check if this field is already in columns + if (!Columns.Any(c => c.Label == sourceField)) + { + Columns.Add(new LabelledColumn( + tableSchema: string.Empty, + tableName: SourceAlias, + columnName: sourceField, + label: sourceField)); + } + } + } + } + } + RuntimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig); // first and after will not be part of query parameters. They will be going into headers instead. // TODO: Revisit 'first' while adding support for TOP queries diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index a7b51d8827..c41f6ef815 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -681,27 +681,283 @@ private void GenerateSourceTargetLinkingObjectDefinitions( /// Hashset of datasourceNames to generate cosmos objects. private DocumentNode GenerateCosmosGraphQLObjects(HashSet dataSourceNames, Dictionary inputObjects) { - DocumentNode? root = null; - - if (dataSourceNames.Count() == 0) + if (dataSourceNames.Count == 0) { return new DocumentNode(new List()); } + DocumentNode root = MergeCosmosSchemaDefinitions(dataSourceNames); + + // Build dictionary of object types so we can modify them to add relationship fields + Dictionary objectTypes = new(); + List nonObjectDefinitions = new(); + + foreach (IDefinitionNode definition in root!.Definitions) + { + if (definition is ObjectTypeDefinitionNode objectNode) + { + string modelName = ObjectTypeToEntityName(objectNode); + objectTypes[modelName] = objectNode; + } + else + { + nonObjectDefinitions.Add(definition); + } + } + + // Cosmos schemas are generated from a .gql root file rather than DB metadata, so the + // relationship fields declared in the runtime config must be grafted onto the object types here. + AddConfiguredRelationshipFieldsToCosmosTypes(objectTypes); + + // Add query arguments to relationship fields (for many-to-* relationships) + foreach ((string entityName, ObjectTypeDefinitionNode node) in objectTypes.ToList()) + { + objectTypes[entityName] = QueryBuilder.AddQueryArgumentsForRelationships(node, inputObjects); + } + + // Generate input types for all object types + foreach (ObjectTypeDefinitionNode node in objectTypes.Values) + { + InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects); + } + + // Rebuild root with modified object types + List allDefinitions = [.. objectTypes.Values, .. nonObjectDefinitions]; + return new DocumentNode(allDefinitions); + } + + /// + /// Merges the GraphQL schema definitions from every CosmosDB data source into a single document. + /// A type name shared by multiple data sources is included only once, and a conflict (the same + /// type name resolving to differing shapes) is surfaced as a config error rather than silently dropped. + /// + private DocumentNode MergeCosmosSchemaDefinitions(HashSet dataSourceNames) + { + List definitions = new(); + // Tracks named type definitions already merged so the same type defined by multiple + // CosmosDB data sources is included only once. + Dictionary seenTypeDefinitions = new(); + foreach (string dataSourceName in dataSourceNames) { ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); DocumentNode currentNode = ((CosmosSqlMetadataProvider)metadataProvider).GraphQLSchemaRoot; - root = root is null ? currentNode : root.WithDefinitions(root.Definitions.Concat(currentNode.Definitions).ToImmutableList()); + + foreach (IDefinitionNode definition in currentNode.Definitions) + { + if (definition is INamedSyntaxNode namedNode) + { + string typeName = namedNode.Name.Value; + if (seenTypeDefinitions.TryGetValue(typeName, out IDefinitionNode? existing)) + { + // The same type name is shared across data sources. This is valid only when + // the definitions are semantically identical; differing shapes indicate a config + // error and must not be silently dropped. The comparison is order-insensitive + // (fields, directives, arguments, etc.) because GraphQL treats these as unordered + // sets, so the same type authored with a different field/directive order in two + // data source schemas is not a conflict. + if (!AreTypeDefinitionsEquivalent(existing, definition)) + { + throw new DataApiBuilderException( + message: $"Conflicting GraphQL type definitions were found for '{typeName}' across CosmosDB data sources. A type name must resolve to the same definition in every data source.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + + continue; + } + + seenTypeDefinitions.Add(typeName, definition); + } + + definitions.Add(definition); + } } - IEnumerable objectNodes = root!.Definitions.Where(d => d is ObjectTypeDefinitionNode).Cast(); - foreach (ObjectTypeDefinitionNode node in objectNodes) + return new DocumentNode(definitions); + } + + /// + /// Determines whether two GraphQL type definitions are semantically equivalent. + /// The definitions are normalized into a canonical form (children such as fields, + /// directives, arguments, interfaces, enum values and union members are sorted by name) + /// before their printed SDL is compared. This avoids false-positive conflicts when the + /// same type is authored with a different ordering across data source schemas, since + /// GraphQL treats these collections as unordered sets. + /// + private static bool AreTypeDefinitionsEquivalent(IDefinitionNode left, IDefinitionNode right) + { + return NormalizeDefinition(left).ToString().Equals(NormalizeDefinition(right).ToString(), StringComparison.Ordinal); + } + + /// + /// Produces a canonical form of a type system definition by recursively sorting its + /// order-insensitive child collections by name. Node kinds that have no order-insensitive + /// children (or are not expected in a CosmosDB schema root) are returned unchanged. + /// + private static IDefinitionNode NormalizeDefinition(IDefinitionNode definition) + { + switch (definition) { - InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects); + case ObjectTypeDefinitionNode obj: + return obj + .WithDirectives(SortDirectives(obj.Directives)) + .WithInterfaces(SortNamedTypes(obj.Interfaces)) + .WithFields(SortFields(obj.Fields)); + case InterfaceTypeDefinitionNode iface: + return iface + .WithDirectives(SortDirectives(iface.Directives)) + .WithInterfaces(SortNamedTypes(iface.Interfaces)) + .WithFields(SortFields(iface.Fields)); + case InputObjectTypeDefinitionNode input: + return input + .WithDirectives(SortDirectives(input.Directives)) + .WithFields(SortInputValues(input.Fields)); + case EnumTypeDefinitionNode enumType: + return enumType + .WithDirectives(SortDirectives(enumType.Directives)) + .WithValues(enumType.Values + .OrderBy(value => value.Name.Value, StringComparer.Ordinal) + .Select(value => value.WithDirectives(SortDirectives(value.Directives))) + .ToList()); + case UnionTypeDefinitionNode union: + return union + .WithDirectives(SortDirectives(union.Directives)) + .WithTypes(SortNamedTypes(union.Types)); + case ScalarTypeDefinitionNode scalar: + return scalar.WithDirectives(SortDirectives(scalar.Directives)); + default: + return definition; } + } + + /// + /// Sorts directives by name and, within each directive, sorts arguments by name. + /// + private static IReadOnlyList SortDirectives(IReadOnlyList directives) + { + return directives + .OrderBy(directive => directive.Name.Value, StringComparer.Ordinal) + .Select(directive => directive.WithArguments( + directive.Arguments.OrderBy(argument => argument.Name.Value, StringComparer.Ordinal).ToList())) + .ToList(); + } - return root; + /// + /// Sorts named type references (e.g. implemented interfaces or union members) by name. + /// + private static IReadOnlyList SortNamedTypes(IReadOnlyList types) + { + return types.OrderBy(type => type.Name.Value, StringComparer.Ordinal).ToList(); + } + + /// + /// Sorts output fields by name and normalizes each field's arguments and directives. + /// + private static IReadOnlyList SortFields(IReadOnlyList fields) + { + return fields + .OrderBy(field => field.Name.Value, StringComparer.Ordinal) + .Select(field => field + .WithArguments(SortInputValues(field.Arguments)) + .WithDirectives(SortDirectives(field.Directives))) + .ToList(); + } + + /// + /// Sorts input values (input fields or field arguments) by name and normalizes their directives. + /// + private static IReadOnlyList SortInputValues(IReadOnlyList values) + { + return values + .OrderBy(value => value.Name.Value, StringComparer.Ordinal) + .Select(value => value.WithDirectives(SortDirectives(value.Directives))) + .ToList(); + } + + /// + /// Grafts the relationship fields declared in the runtime config onto the matching CosmosDB object + /// types. Any embedded field of the same name is replaced by the relationship field, and when the + /// source and target share a container the embedded target projection (fields whose GraphQL type is + /// the target type) is stripped from the source. Stripping is keyed off the field's type rather than + /// a name match, so the source's own scalar fields that merely share a name with a target field + /// (e.g. id, name) are preserved. Join/source fields are always preserved. + /// + private void AddConfiguredRelationshipFieldsToCosmosTypes(Dictionary objectTypes) + { + RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); + foreach ((string entityName, Entity entity) in runtimeConfig.Entities) + { + DataSource ds = runtimeConfig.GetDataSourceFromEntityName(entityName); + if (ds.DatabaseType != DatabaseType.CosmosDB_NoSQL + || !entity.GraphQL.Enabled + || entity.Relationships is null || entity.Relationships.Count == 0 + || !objectTypes.TryGetValue(entityName, out ObjectTypeDefinitionNode? objectNode)) + { + continue; + } + + List fields = objectNode.Fields.ToList(); + + // Source fields participate in joins, so they must survive the embedded-field stripping below. + HashSet sourceFieldsInRelationships = entity.Relationships.Values + .Where(r => r.SourceFields is not null) + .SelectMany(r => r.SourceFields!) + .ToHashSet(); + + // The relationship fields grafted below are themselves typed as their target type. When + // multiple relationships on this entity target the same shared-container type, the + // type-based stripping below would otherwise remove a relationship field added on an + // earlier iteration (leaving only the last one). Preserving all relationship names keeps + // every grafted relationship field intact. + HashSet relationshipNames = entity.Relationships.Keys.ToHashSet(); + + foreach ((string relationshipName, EntityRelationship relationship) in entity.Relationships) + { + string targetEntityName = relationship.TargetEntity; + if (!runtimeConfig.Entities.TryGetValue(targetEntityName, out Entity? targetEntity)) + { + continue; + } + + // Replace any existing field of the same name (e.g. an embedded array) with the relationship field. + fields.RemoveAll(f => f.Name.Value == relationshipName); + + // When source and target share a container, the target's data is embedded in the + // source document. Strip only the embedded projection of the target, identified + // positively by GraphQL type (the source field's named type equals the target type), + // rather than by a name intersection. This avoids dropping the source's own scalar + // fields that merely share a name with a target field (e.g. id, name). Join/source + // fields and already-grafted relationship fields are always preserved. + if (entity.Source.Object == targetEntity.Source.Object) + { + string targetTypeName = GetDefinedSingularName(targetEntityName, targetEntity); + fields.RemoveAll(f => + f.Type.NamedType().Name.Value == targetTypeName + && !sourceFieldsInRelationships.Contains(f.Name.Value) + && !relationshipNames.Contains(f.Name.Value)); + } + + INullableTypeNode fieldType = relationship.Cardinality == Cardinality.One + ? new NamedTypeNode(GetDefinedSingularName(targetEntityName, targetEntity)) + : new NamedTypeNode(QueryBuilder.GeneratePaginationTypeName(GetDefinedSingularName(targetEntityName, targetEntity))); + + DirectiveNode relationshipDirective = new( + RelationshipDirectiveType.DirectiveName, + new ArgumentNode("target", GetDefinedSingularName(targetEntityName, targetEntity)), + new ArgumentNode("cardinality", relationship.Cardinality.ToString())); + + fields.Add(new FieldDefinitionNode( + location: null, + new NameNode(relationshipName), + description: null, + new List(), + fieldType, + new List { relationshipDirective })); + } + + objectTypes[entityName] = objectNode.WithFields(fields); + } } /// @@ -729,11 +985,12 @@ private static FieldDefinitionNode GetDbOperationResultField() foreach ((string entityName, Entity entity) in runtimeConfig.Entities) { DataSource ds = runtimeConfig.GetDataSourceFromEntityName(entityName); + string dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); switch (ds.DatabaseType) { case DatabaseType.CosmosDB_NoSQL: - cosmosDataSourceNames.Add(_runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName)); + cosmosDataSourceNames.Add(dataSourceName); break; case DatabaseType.MSSQL or DatabaseType.MySQL or DatabaseType.PostgreSQL or DatabaseType.DWSQL: sqlEntities.TryAdd(entityName, entity); diff --git a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs index b6d5dd0111..51070699c0 100644 --- a/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/CosmosSqlMetadataProvider.cs @@ -55,10 +55,11 @@ public class CosmosSqlMetadataProvider : ISqlMetadataProvider public List SqlMetadataExceptions { get; private set; } = new(); - public CosmosSqlMetadataProvider(RuntimeConfigProvider runtimeConfigProvider, RuntimeConfigValidator runtimeConfigValidator, IFileSystem fileSystem) + public CosmosSqlMetadataProvider(RuntimeConfigProvider runtimeConfigProvider, RuntimeConfigValidator runtimeConfigValidator, IFileSystem fileSystem, string dataSourceName) { RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); _fileSystem = fileSystem; + // Many classes have references to the RuntimeConfig, therefore to guarantee // that the Runtime Entities are not mutated by another class we make a copy of them // to store internally. @@ -66,7 +67,11 @@ public CosmosSqlMetadataProvider(RuntimeConfigProvider runtimeConfigProvider, Ru _isDevelopmentMode = runtimeConfig.IsDevelopmentMode(); _databaseType = runtimeConfig.DataSource!.DatabaseType; - CosmosDbNoSQLDataSourceOptions? cosmosDb = runtimeConfig.DataSource.GetTypedOptions(); + // Get the data source for this specific dataSourceName instead of always using the default + DataSource dataSource = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName); + _databaseType = dataSource.DatabaseType; + + CosmosDbNoSQLDataSourceOptions? cosmosDb = dataSource.GetTypedOptions(); if (cosmosDb is null) { diff --git a/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs b/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs index 6fe20969ed..c5842f9396 100644 --- a/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs +++ b/src/Core/Services/MetadataProviders/MetadataProviderFactory.cs @@ -51,7 +51,7 @@ private void ConfigureMetadataProviders() { ISqlMetadataProvider metadataProvider = dataSource.DatabaseType switch { - DatabaseType.CosmosDB_NoSQL => new CosmosSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _fileSystem), + DatabaseType.CosmosDB_NoSQL => new CosmosSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _fileSystem, dataSourceName), DatabaseType.MSSQL => new MsSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), DatabaseType.DWSQL => new MsSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), DatabaseType.PostgreSQL => new PostgreSqlMetadataProvider(_runtimeConfigProvider, _runtimeConfigValidator, _queryManagerFactory, _logger, dataSourceName, _isValidateOnly), diff --git a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs index 6ceb4445d3..1ecb2a8174 100644 --- a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs @@ -50,6 +50,16 @@ public static DocumentNode Build( { string dbEntityName = ObjectTypeToEntityName(objectTypeDefinitionNode); NameNode name = objectTypeDefinitionNode.Name; + + // A Cosmos schema file can declare @model types that are not registered as entities + // in the runtime config (e.g. shared schema files). Such types have no mutations to + // generate, so they are intentionally skipped. Genuine type-name conflicts across + // data sources are detected earlier in GraphQLSchemaCreator. + if (!entities.ContainsKey(dbEntityName)) + { + continue; + } + Entity entity = entities[dbEntityName]; // For stored procedures, only one mutation is created in the schema // unlike table/views where we create one for each CUD operation. diff --git a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs index 5441dcc766..575dae0e7f 100644 --- a/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/InputTypeBuilder.cs @@ -23,6 +23,14 @@ IDictionary inputTypes ) { List inputFields = GenerateFilterInputFieldsForBuiltInFields(node, inputTypes); + + // Only create Filter input type if there are actual filterable fields + // Types with only nested objects shouldn't have Filter inputs (just "and"/"or" would be circular/useless) + if (inputFields.Count == 0) + { + return; + } + string filterInputName = GenerateObjectInputFilterName(node); GenerateFilterInputTypeFromInputFields(inputTypes, inputFields, filterInputName, $"Filter input for {node.Name} GraphQL type"); } @@ -30,19 +38,31 @@ IDictionary inputTypes internal static void GenerateOrderByInputTypeForObjectType(ObjectTypeDefinitionNode node, IDictionary inputTypes) { List inputFields = GenerateOrderByInputFieldsForBuiltInFields(node); + + // Only create OrderBy input type if there are actual orderable fields (scalars) + // Types with only nested objects (like Accents, Palette) shouldn't have OrderBy inputs + if (inputFields.Count == 0) + { + return; + } + string orderByInputName = GenerateObjectInputOrderByName(node); // OrderBy does not include "and" and "or" input types so we add only the orderByInputName here. - inputTypes.Add( - orderByInputName, - new( - location: null, - new NameNode(orderByInputName), - new StringValueNode($"Order by input for {node.Name} GraphQL type"), - new List(), - inputFields - ) - ); + // Check if the input type already exists to avoid duplicate key errors when using multiple data sources + if (!inputTypes.ContainsKey(orderByInputName)) + { + inputTypes.Add( + orderByInputName, + new( + location: null, + new NameNode(orderByInputName), + new StringValueNode($"Order by input for {node.Name} GraphQL type"), + new List(), + inputFields + ) + ); + } } private static List GenerateOrderByInputFieldsForBuiltInFields(ObjectTypeDefinitionNode node) @@ -99,16 +119,20 @@ private static void GenerateFilterInputTypeFromInputFields( defaultValue: null, new List())); - inputTypes.Add( - inputTypeName, - new( - location: null, - new NameNode(inputTypeName), - new StringValueNode(inputTypeDescription), - new List(), - inputFields - ) - ); + // Check if the input type already exists to avoid duplicate key errors when using multiple data sources + if (!inputTypes.ContainsKey(inputTypeName)) + { + inputTypes.Add( + inputTypeName, + new( + location: null, + new NameNode(inputTypeName), + new StringValueNode(inputTypeDescription), + new List(), + inputFields + ) + ); + } } private static List GenerateFilterInputFieldsForBuiltInFields( @@ -130,14 +154,32 @@ private static List GenerateFilterInputFieldsForBuiltI } string fieldTypeName = field.Type.NamedType().Name.Value; + bool isListType = field.Type.IsListType(); + if (IsBuiltInType(field.Type)) { - if (!inputTypes.ContainsKey(fieldTypeName)) + InputObjectTypeDefinitionNode inputType; + string inputTypeName; + + if (isListType) { - inputTypes.Add(fieldTypeName, StandardQueryInputs.GetFilterTypeByScalar(fieldTypeName)); + inputTypeName = $"{fieldTypeName}List"; + if (!inputTypes.ContainsKey(inputTypeName)) + { + inputTypes.Add(inputTypeName, StandardQueryInputs.GetListFilterTypeByScalar(fieldTypeName)); + } + + inputType = inputTypes[inputTypeName]; } + else + { + if (!inputTypes.ContainsKey(fieldTypeName)) + { + inputTypes.Add(fieldTypeName, StandardQueryInputs.GetFilterTypeByScalar(fieldTypeName)); + } - InputObjectTypeDefinitionNode inputType = inputTypes[fieldTypeName]; + inputType = inputTypes[fieldTypeName]; + } inputFields.Add( new( diff --git a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs index a2cc63b2c2..1f54686624 100644 --- a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs @@ -67,6 +67,16 @@ public static DocumentNode Build( { NameNode name = objectTypeDefinitionNode.Name; string entityName = ObjectTypeToEntityName(objectTypeDefinitionNode); + + // A Cosmos schema file can declare @model types that are not registered as entities + // in the runtime config (e.g. shared schema files). Such types have no queries to + // generate, so they are intentionally skipped. Genuine type-name conflicts across + // data sources are detected earlier in GraphQLSchemaCreator. + if (!entities.ContainsKey(entityName)) + { + continue; + } + Entity entity = entities[entityName]; if (entity.Source.Type is EntitySourceType.StoredProcedure) diff --git a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs index 539a0b356e..739e04fb84 100644 --- a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs +++ b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs @@ -52,6 +52,16 @@ public sealed class StandardQueryInputs private static readonly NameNode _in = new("in"); private static readonly StringValueNode _inDescription = new("In"); + // List filter operations + private static readonly NameNode _some = new("some"); + private static readonly StringValueNode _someDescription = new("At least one element matches the filter"); + private static readonly NameNode _none = new("none"); + private static readonly StringValueNode _noneDescription = new("No elements match the filter"); + private static readonly NameNode _all = new("all"); + private static readonly StringValueNode _allDescription = new("All elements match the filter"); + private static readonly NameNode _any = new("any"); + private static readonly StringValueNode _anyDescription = new("List is not empty"); + private static InputObjectTypeDefinitionNode IdInputType() => CreateSimpleEqualsFilter("IdFilterInput", "Input type for adding ID filters", _id); @@ -163,6 +173,69 @@ private static InputObjectTypeDefinitionNode CreateStringFilter( ] ); + private static InputObjectTypeDefinitionNode CreateListFilter( + string name, + string description, + string elementFilterTypeName, + ITypeNode elementScalarType) => + new( + location: null, + new NameNode(name), + new StringValueNode(description), + [], + [ + // Element-aware quantifiers: take a filter on the element type. + new(null, _some, _someDescription, new NamedTypeNode(elementFilterTypeName), null, []), + new(null, _none, _noneDescription, new NamedTypeNode(elementFilterTypeName), null, []), + new(null, _all, _allDescription, new NamedTypeNode(elementFilterTypeName), null, []), + // Whole-list operators that are well defined for any element scalar type. + new(null, _any, _anyDescription, _boolean, null, []), + new(null, _isNull, _isNullDescription, _boolean, null, []), + // contains/notContains map to ARRAY_CONTAINS over the element value. + new(null, _contains, _containsDescription, elementScalarType, null, []), + new(null, _notContains, _notContainsDescription, elementScalarType, null, []) + ] + ); + + private static InputObjectTypeDefinitionNode IdListInputType() => + CreateListFilter("IdListFilterInput", "Input type for adding list of ID filters", "IdFilterInput", _id); + + private static InputObjectTypeDefinitionNode BooleanListInputType() => + CreateListFilter("BooleanListFilterInput", "Input type for adding list of Boolean filters", "BooleanFilterInput", _boolean); + + private static InputObjectTypeDefinitionNode ByteListInputType() => + CreateListFilter("ByteListFilterInput", "Input type for adding list of Byte filters", "ByteFilterInput", _byte); + + private static InputObjectTypeDefinitionNode ShortListInputType() => + CreateListFilter("ShortListFilterInput", "Input type for adding list of Short filters", "ShortFilterInput", _short); + + private static InputObjectTypeDefinitionNode IntListInputType() => + CreateListFilter("IntListFilterInput", "Input type for adding list of Int filters", "IntFilterInput", _int); + + private static InputObjectTypeDefinitionNode LongListInputType() => + CreateListFilter("LongListFilterInput", "Input type for adding list of Long filters", "LongFilterInput", _long); + + private static InputObjectTypeDefinitionNode SingleListInputType() => + CreateListFilter("SingleListFilterInput", "Input type for adding list of Single filters", "SingleFilterInput", _single); + + private static InputObjectTypeDefinitionNode FloatListInputType() => + CreateListFilter("FloatListFilterInput", "Input type for adding list of Float filters", "FloatFilterInput", _float); + + private static InputObjectTypeDefinitionNode DecimalListInputType() => + CreateListFilter("DecimalListFilterInput", "Input type for adding list of Decimal filters", "DecimalFilterInput", _decimal); + + private static InputObjectTypeDefinitionNode StringListInputType() => + CreateListFilter("StringListFilterInput", "Input type for adding list of String filters", "StringFilterInput", _string); + + private static InputObjectTypeDefinitionNode DateTimeListInputType() => + CreateListFilter("DateTimeListFilterInput", "Input type for adding list of DateTime filters", "DateTimeFilterInput", _dateTime); + + private static InputObjectTypeDefinitionNode LocalTimeListInputType() => + CreateListFilter("LocalTimeListFilterInput", "Input type for adding list of LocalTime filters", "LocalTimeFilterInput", _localTime); + + private static InputObjectTypeDefinitionNode UuidListInputType() => + CreateListFilter("UuidListFilterInput", "Input type for adding list of Uuid filters", "UuidFilterInput", _uuid); + /// /// Gets a filter input object type by the corresponding scalar type name. /// @@ -175,6 +248,18 @@ private static InputObjectTypeDefinitionNode CreateStringFilter( public static InputObjectTypeDefinitionNode GetFilterTypeByScalar(string scalarTypeName) => _instance._inputMap[scalarTypeName]; + /// + /// Gets a list filter input object type by the corresponding scalar type name. + /// + /// + /// The scalar type name (of the list element). + /// + /// + /// The list filter input object type. + /// + public static InputObjectTypeDefinitionNode GetListFilterTypeByScalar(string scalarTypeName) + => _instance._listInputMap[scalarTypeName]; + /// /// Specifies if the given type name is a standard filter input object type. /// @@ -189,6 +274,7 @@ public static bool IsFilterType(string filterTypeName) private static readonly StandardQueryInputs _instance = new(); private readonly Dictionary _inputMap = []; + private readonly Dictionary _listInputMap = []; private readonly HashSet _standardQueryInputNames = []; private StandardQueryInputs() @@ -212,11 +298,32 @@ private StandardQueryInputs() AddInputType(BYTEARRAY_TYPE, Base64StringInputType()); AddInputType(ScalarNames.LocalTime, LocalTimeInputType()); + // Add list filter types + AddListInputType(ScalarNames.ID, IdListInputType()); + AddListInputType(ScalarNames.UUID, UuidListInputType()); + AddListInputType(ScalarNames.Byte, ByteListInputType()); + AddListInputType(ScalarNames.Short, ShortListInputType()); + AddListInputType(ScalarNames.Int, IntListInputType()); + AddListInputType(ScalarNames.Long, LongListInputType()); + AddListInputType(SINGLE_TYPE, SingleListInputType()); + AddListInputType(ScalarNames.Float, FloatListInputType()); + AddListInputType(ScalarNames.Decimal, DecimalListInputType()); + AddListInputType(ScalarNames.Boolean, BooleanListInputType()); + AddListInputType(ScalarNames.String, StringListInputType()); + AddListInputType(ScalarNames.DateTime, DateTimeListInputType()); + AddListInputType(ScalarNames.LocalTime, LocalTimeListInputType()); + void AddInputType(string inputTypeName, InputObjectTypeDefinitionNode inputType) { _inputMap.Add(inputTypeName, inputType); _standardQueryInputNames.Add(inputType.Name.Value); } + + void AddListInputType(string inputTypeName, InputObjectTypeDefinitionNode inputType) + { + _listInputMap.Add(inputTypeName, inputType); + _standardQueryInputNames.Add(inputType.Name.Value); + } } } } diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 71ef6ed6cf..2a185dff61 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -3892,7 +3892,7 @@ public void ValidateGraphQLSchemaForCircularReference(string schema) RuntimeConfigValidator validator = new(provider, fileSystem, loggerValidator.Object); DataApiBuilderException exception = - Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, validator, fileSystem)); + Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, validator, fileSystem, provider.GetConfig().DefaultDataSourceName)); 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); @@ -3946,7 +3946,7 @@ type Planet @model(name:""PlanetAlias"") { RuntimeConfigValidator validator = new(provider, fileSystem, loggerValidator.Object); DataApiBuilderException exception = - Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, validator, fileSystem)); + Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, validator, fileSystem, provider.GetConfig().DefaultDataSourceName)); Assert.AreEqual("The entity 'Character' was not found in the runtime config.", exception.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode); Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode); diff --git a/src/Service.Tests/CosmosTests/QueryFilterTests.cs b/src/Service.Tests/CosmosTests/QueryFilterTests.cs index 636988331c..536d80d6b4 100644 --- a/src/Service.Tests/CosmosTests/QueryFilterTests.cs +++ b/src/Service.Tests/CosmosTests/QueryFilterTests.cs @@ -1306,6 +1306,86 @@ public async Task TestQueryFilterNotContains_WithStringArray() await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery); } + /// + /// Tests that the field level query filter work with list type for 'some' and 'contains' operators + /// + [TestMethod] + public async Task TestQueryFilterSomeContains_WithStringArray() + { + string gqlQuery = @"{ + planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { some: { contains : ""tag1""}}}) + { + items { + id + name + } + } + }"; + + string dbQuery = $"SELECT c.id, c.name FROM c where EXISTS(SELECT VALUE 1 FROM t IN c.tags WHERE t LIKE \"%tag1%\")"; + await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery); + } + + /// + /// Tests that the field level query filter work with list type for 'none' and 'contains' operators + /// + [TestMethod] + public async Task TestQueryFilterNoneContains_WithStringArray() + { + string gqlQuery = @"{ + planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { none: { contains : ""tagg1""}}}) + { + items { + id + name + } + } + }"; + + string dbQuery = $"SELECT c.id, c.name FROM c where NOT EXISTS(SELECT VALUE 1 FROM t IN c.tags WHERE t LIKE \"%tagg1%\")"; + await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery); + } + + /// + /// Tests that the field level query filters work with list type for 'all' and 'contains' operators + /// + [TestMethod] + public async Task TestQueryFilterAllContains_WithStringArray() + { + string gqlQuery = @"{ + planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { all: { contains : ""tag""}}}) + { + items { + id + name + } + } + }"; + + string dbQuery = $"SELECT c.id, c.name FROM c where NOT EXISTS(SELECT VALUE 1 FROM t IN c.tags WHERE NOT t LIKE \"%tag%\")"; + await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery); + } + + /// + /// Tests that the field level query filter work with list type for 'any' operator at true + /// + [TestMethod] + public async Task TestQueryFilterAny_WithStringArray() + { + string gqlQuery = @"{ + planets(" + QueryBuilder.FILTER_FIELD_NAME + @" : {tags: { any: true}}) + { + items { + id + name + } + } + }"; + + string dbQuery = $"SELECT c.id, c.name FROM c where ARRAY_LENGTH(c.tags) > 0"; + await ExecuteAndValidateResult(_graphQLQueryName, gqlQuery, dbQuery); + } + /// /// Tests that the pk level query filter is working with variables. /// diff --git a/src/Service.Tests/CosmosTests/TestBase.cs b/src/Service.Tests/CosmosTests/TestBase.cs index 10f7a7bcd2..a908d25dc2 100644 --- a/src/Service.Tests/CosmosTests/TestBase.cs +++ b/src/Service.Tests/CosmosTests/TestBase.cs @@ -162,7 +162,7 @@ protected WebApplicationFactory SetupTestApplicationFactory() Mock> loggerValidator = new(); RuntimeConfigValidator validator = new(provider, fileSystem, loggerValidator.Object); - ISqlMetadataProvider cosmosSqlMetadataProvider = new CosmosSqlMetadataProvider(provider, validator, fileSystem); + ISqlMetadataProvider cosmosSqlMetadataProvider = new CosmosSqlMetadataProvider(provider, validator, fileSystem, provider.GetConfig().DefaultDataSourceName); Mock metadataProviderFactory = new(); metadataProviderFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(cosmosSqlMetadataProvider); diff --git a/src/Service.Tests/GraphQLBuilder/MultiSourceBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MultiSourceBuilderTests.cs index 8cfeb36e61..441b773ceb 100644 --- a/src/Service.Tests/GraphQLBuilder/MultiSourceBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MultiSourceBuilderTests.cs @@ -13,6 +13,7 @@ using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.GraphQLBuilder.Directives; using HotChocolate; using HotChocolate.Language; using Microsoft.Extensions.Logging; @@ -192,5 +193,301 @@ public void EnsureQueryHasAtLeastOneField_ReturnsOriginal_WhenNoQueryDefinitionP Assert.AreSame(input, result, "Expected the original DocumentNode when no Query is present."); } + + /// + /// When a CosmosDB relationship's source and target entities are backed by the same container, + /// the target's data is embedded in the source document. Grafting the configured relationship + /// must strip only the embedded projection of the target (fields whose GraphQL type is the target + /// type) and must NOT drop the source's own scalar fields that merely share a name with a target + /// field. Here both Planet and Character map to graphqldb.planet, and + /// Character also has id/name scalars, so a name-based strip would wrongly + /// remove the planet's own id/name. This is the regression guard for that bug. + /// + [TestMethod] + public async Task CosmosRelationship_SharedContainer_PreservesSourceScalars_AndStripsEmbeddedTargetProjection() + { + // Planet and Character share the same container; Planet declares a one-cardinality + // relationship named "character" targeting Character. + ObjectTypeDefinitionNode planet = await BuildCosmosPlanetTypeAsync( + characterContainer: "graphqldb.planet", + relationshipName: "character", + cardinality: "one"); + + HashSet fieldNames = planet.Fields.Select(f => f.Name.Value).ToHashSet(); + + // The source's own scalar fields survive even though Character also defines id/name. + Assert.IsTrue(fieldNames.Contains("id"), "Planet's own 'id' scalar must be preserved."); + Assert.IsTrue(fieldNames.Contains("name"), "Planet's own 'name' scalar must be preserved."); + Assert.IsTrue(fieldNames.Contains("age"), "Planet's own 'age' scalar must be preserved."); + Assert.IsTrue(fieldNames.Contains("dimension"), "Planet's own 'dimension' scalar must be preserved."); + + // 'stars' is typed [Star], not the target type, so it is not part of the embedded + // Character projection and must be preserved. + Assert.IsTrue(fieldNames.Contains("stars"), "Fields typed as a non-target type must be preserved."); + + // The 'character' field is now the configured relationship (replacing the embedded projection). + FieldDefinitionNode characterField = planet.Fields.Single(f => f.Name.Value == "character"); + Assert.IsTrue( + characterField.Directives.Any(d => d.Name.Value == RelationshipDirectiveType.DirectiveName), + "The 'character' field should carry the @relationship directive."); + Assert.AreEqual( + "Character", + characterField.Type.NamedType().Name.Value, + "A one-cardinality relationship field should be typed as the target type."); + } + + /// + /// When the source and target entities are backed by different containers, the target is NOT + /// embedded in the source document, so no type-based stripping should occur. Only an + /// equally-named existing field is replaced by the configured relationship. Here Planet and + /// Character live in different containers and the relationship is named "characters" (distinct + /// from the embedded "character" field), so the embedded character: Character field must + /// remain untouched alongside the new relationship field. + /// + [TestMethod] + public async Task CosmosRelationship_DifferentContainers_DoesNotStripTargetTypedFields() + { + ObjectTypeDefinitionNode planet = await BuildCosmosPlanetTypeAsync( + characterContainer: "graphqldb.character", + relationshipName: "characters", + cardinality: "many"); + + HashSet fieldNames = planet.Fields.Select(f => f.Name.Value).ToHashSet(); + + // No shared container => the embedded Character projection is left in place. + Assert.IsTrue(fieldNames.Contains("character"), "Embedded 'character' field must be preserved across containers."); + Assert.IsTrue(fieldNames.Contains("id"), "Planet's own 'id' scalar must be preserved."); + Assert.IsTrue(fieldNames.Contains("name"), "Planet's own 'name' scalar must be preserved."); + Assert.IsTrue(fieldNames.Contains("stars"), "Planet's 'stars' field must be preserved."); + + // The configured relationship field is still added. + FieldDefinitionNode charactersField = planet.Fields.Single(f => f.Name.Value == "characters"); + Assert.IsTrue( + charactersField.Directives.Any(d => d.Name.Value == RelationshipDirectiveType.DirectiveName), + "The 'characters' field should carry the @relationship directive."); + } + + /// + /// When a single entity declares multiple relationships that target the SAME type backed by the + /// SAME container as the source, every grafted relationship field must be preserved. The + /// shared-container path strips embedded projections of the target type (fields typed as the + /// target); this must not remove relationship fields grafted on earlier iterations, which are + /// themselves typed as the target. Prior to the fix only the last relationship survived. Here + /// Planet declares both "characterA" and "characterB" targeting Character (same container), so + /// both relationship fields must exist. This is the regression guard for that bug. + /// + [TestMethod] + public async Task CosmosRelationship_MultipleRelationshipsToSameSharedContainerTarget_AllPreserved() + { + string configContents = BuildCosmosMultiRelationshipConfig( + characterContainer: "graphqldb.planet", + relationshipNames: new[] { "characterA", "characterB" }); + string cosmosSchemaContents = await File.ReadAllTextAsync("schema.gql"); + + IFileSystem fs = new MockFileSystem(new Dictionary() + { + { "dab-config.json", new MockFileData(configContents) }, + { "schema.gql", new MockFileData(cosmosSchemaContents) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + RuntimeConfigProvider provider = new(loader); + + Mock> loggerValidator = new(); + RuntimeConfigValidator validator = new(provider, fs, loggerValidator.Object); + + Mock queryManagerFactory = new(); + Mock queryEngineFactory = new(); + Mock mutationEngineFactory = new(); + Mock> logger = new(); + IMetadataProviderFactory metadataProviderFactory = new MetadataProviderFactory(provider, validator, queryManagerFactory.Object, logger.Object, fs, handler: null); + Mock authResolver = new(); + + GraphQLSchemaCreator creator = new(provider, queryEngineFactory.Object, mutationEngineFactory.Object, metadataProviderFactory, authResolver.Object); + (DocumentNode root, _) = creator.GenerateGraphQLObjects(); + + ObjectTypeDefinitionNode planet = root.Definitions + .OfType() + .Single(d => d.Name.Value == "Planet"); + + HashSet fieldNames = planet.Fields.Select(f => f.Name.Value).ToHashSet(); + + // Both relationship fields targeting the same shared-container type must survive. + Assert.IsTrue(fieldNames.Contains("characterA"), "First relationship field 'characterA' must be preserved."); + Assert.IsTrue(fieldNames.Contains("characterB"), "Second relationship field 'characterB' must be preserved."); + + foreach (string relationshipName in new[] { "characterA", "characterB" }) + { + FieldDefinitionNode field = planet.Fields.Single(f => f.Name.Value == relationshipName); + Assert.IsTrue( + field.Directives.Any(d => d.Name.Value == RelationshipDirectiveType.DirectiveName), + $"The '{relationshipName}' field should carry the @relationship directive."); + Assert.AreEqual( + "Character", + field.Type.NamedType().Name.Value, + $"The '{relationshipName}' relationship field should be typed as the target type."); + } + } + + /// + /// Builds the Cosmos GraphQL schema for a Planet/Character/Star configuration and returns the + /// generated Planet object type. The Character entity's container and the Planet->Character + /// relationship are parameterized so callers can exercise the shared- and separate-container paths. + /// + private static async Task BuildCosmosPlanetTypeAsync( + string characterContainer, + string relationshipName, + string cardinality) + { + string configContents = BuildCosmosRelationshipConfig(characterContainer, relationshipName, cardinality); + string cosmosSchemaContents = await File.ReadAllTextAsync("schema.gql"); + + IFileSystem fs = new MockFileSystem(new Dictionary() + { + { "dab-config.json", new MockFileData(configContents) }, + { "schema.gql", new MockFileData(cosmosSchemaContents) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + RuntimeConfigProvider provider = new(loader); + + Mock> loggerValidator = new(); + RuntimeConfigValidator validator = new(provider, fs, loggerValidator.Object); + + Mock queryManagerFactory = new(); + Mock queryEngineFactory = new(); + Mock mutationEngineFactory = new(); + Mock> logger = new(); + IMetadataProviderFactory metadataProviderFactory = new MetadataProviderFactory(provider, validator, queryManagerFactory.Object, logger.Object, fs, handler: null); + Mock authResolver = new(); + + GraphQLSchemaCreator creator = new(provider, queryEngineFactory.Object, mutationEngineFactory.Object, metadataProviderFactory, authResolver.Object); + (DocumentNode root, _) = creator.GenerateGraphQLObjects(); + + return root.Definitions + .OfType() + .Single(d => d.Name.Value == "Planet"); + } + + /// + /// Produces a minimal CosmosDB runtime config with Planet (PlanetAlias), Character and Star + /// entities sharing the schema.gql schema. Planet maps to graphqldb.planet and + /// declares the supplied relationship to Character; Character maps to the supplied container, + /// which the caller sets equal to Planet's container to exercise the embedded (shared-container) path. + /// + /// + /// Produces a minimal CosmosDB runtime config with a Planet (PlanetAlias) entity that declares + /// multiple one-cardinality relationships (named per ) all + /// targeting the Character entity, plus the Character and Star entities. Character maps to the + /// supplied container, which callers set equal to Planet's container (graphqldb.planet) to + /// exercise the shared-container embedded-projection stripping path. + /// + private static string BuildCosmosMultiRelationshipConfig(string characterContainer, string[] relationshipNames) + { + // Local CosmosDB emulator well-known endpoint/key; not a secret. + const string connectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + + string relationshipsJson = string.Join(",\n", relationshipNames.Select(name => $$""" + "{{name}}": { + "cardinality": "one", + "target.entity": "Character" + } + """)); + + return $$""" + { + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", + "data-source": { + "database-type": "cosmosdb_nosql", + "connection-string": "{{connectionString}}", + "options": { + "database": "graphqldb", + "container": "planet", + "schema": "schema.gql" + } + }, + "runtime": { + "rest": { "enabled": false, "path": "/api" }, + "graphql": { "enabled": true, "path": "/graphql", "allow-introspection": true }, + "host": { "authentication": { "provider": "StaticWebApps" }, "mode": "development" } + }, + "entities": { + "PlanetAlias": { + "source": { "object": "graphqldb.planet" }, + "graphql": { "enabled": true, "type": { "singular": "Planet", "plural": "Planets" } }, + "rest": { "enabled": false }, + "permissions": [ { "role": "anonymous", "actions": [ { "action": "*" } ] } ], + "relationships": { + {{relationshipsJson}} + } + }, + "Character": { + "source": { "object": "{{characterContainer}}" }, + "graphql": { "enabled": true, "type": { "singular": "Character", "plural": "Characters" } }, + "rest": { "enabled": false }, + "permissions": [ { "role": "anonymous", "actions": [ { "action": "*" } ] } ] + }, + "Star": { + "source": { "object": "graphqldb.star" }, + "graphql": { "enabled": true, "type": { "singular": "Star", "plural": "Stars" } }, + "rest": { "enabled": false }, + "permissions": [ { "role": "anonymous", "actions": [ { "action": "*" } ] } ] + } + } + } + """; + } + + private static string BuildCosmosRelationshipConfig(string characterContainer, string relationshipName, string cardinality) + { + // Local CosmosDB emulator well-known endpoint/key; not a secret. + const string connectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + + return $$""" + { + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json", + "data-source": { + "database-type": "cosmosdb_nosql", + "connection-string": "{{connectionString}}", + "options": { + "database": "graphqldb", + "container": "planet", + "schema": "schema.gql" + } + }, + "runtime": { + "rest": { "enabled": false, "path": "/api" }, + "graphql": { "enabled": true, "path": "/graphql", "allow-introspection": true }, + "host": { "authentication": { "provider": "StaticWebApps" }, "mode": "development" } + }, + "entities": { + "PlanetAlias": { + "source": { "object": "graphqldb.planet" }, + "graphql": { "enabled": true, "type": { "singular": "Planet", "plural": "Planets" } }, + "rest": { "enabled": false }, + "permissions": [ { "role": "anonymous", "actions": [ { "action": "*" } ] } ], + "relationships": { + "{{relationshipName}}": { + "cardinality": "{{cardinality}}", + "target.entity": "Character" + } + } + }, + "Character": { + "source": { "object": "{{characterContainer}}" }, + "graphql": { "enabled": true, "type": { "singular": "Character", "plural": "Characters" } }, + "rest": { "enabled": false }, + "permissions": [ { "role": "anonymous", "actions": [ { "action": "*" } ] } ] + }, + "Star": { + "source": { "object": "graphqldb.star" }, + "graphql": { "enabled": true, "type": { "singular": "Star", "plural": "Stars" } }, + "rest": { "enabled": false }, + "permissions": [ { "role": "anonymous", "actions": [ { "action": "*" } ] } ] + } + } + } + """; + } } }