Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ private static List<SchemaCoordinatePath> FindPathsToRoot(
{
var rootTypeNames = data.RootTypeNames;
var reverseMap = data.ReverseMap;
var abstractTypeMap = data.AbstractTypeMap;
var startTypeName = coordinate.Name;
var paths = new List<SchemaCoordinatePath>();

Expand Down Expand Up @@ -141,38 +142,56 @@ private static List<SchemaCoordinatePath> FindPathsToRoot(

var (currentType, currentPath) = queue.Dequeue();

if (!reverseMap.TryGetValue(currentType, out var references))
if (reverseMap.TryGetValue(currentType, out var references))
{
continue;
}

foreach (var reference in references)
{
if (!visited.Add(reference.Name))
foreach (var reference in references)
{
continue;
}

var newPath = new List<SchemaCoordinate>(currentPath.Count + 1) { reference };
newPath.AddRange(currentPath);
var isRootReference = rootTypeNames.Contains(reference.Name);

if (rootTypeNames.Contains(reference.Name))
{
if (coordinate.MemberName is not null)
// Only dedupe non-root hops. A root reference completes a path instead of
// continuing the traversal, so marking it visited would suppress additional
// distinct paths through other fields on the same root type.
if (!isRootReference && !visited.Add(reference.Name))
{
newPath.Add(coordinate);
continue;
}

paths.Add(new SchemaCoordinatePath(CollectionsMarshal.AsSpan(newPath)));
var newPath = new List<SchemaCoordinate>(currentPath.Count + 1) { reference };
newPath.AddRange(currentPath);

if (isRootReference)
{
if (coordinate.MemberName is not null)
{
newPath.Add(coordinate);
}

if (paths.Count >= maxPaths)
paths.Add(new SchemaCoordinatePath(CollectionsMarshal.AsSpan(newPath)));

if (paths.Count >= maxPaths)
{
return paths;
}
}
else
{
break;
queue.Enqueue((reference.Name, newPath));
}
}
else
}

// A concrete type is also reachable wherever one of its abstract types
// (an implemented interface, or a union it belongs to) is referenced. Step up to
// those abstractions without extending the path, since narrowing from an abstract
// type to this type does not traverse a field.
if (abstractTypeMap.TryGetValue(currentType, out var abstractTypes))
{
foreach (var abstractType in abstractTypes)
{
queue.Enqueue((reference.Name, newPath));
if (visited.Add(abstractType))
{
queue.Enqueue((abstractType, currentPath));
}
}
}
}
Expand All @@ -194,7 +213,7 @@ private SearchData EnsureIndex()
return _searchData;
}

var (documents, reverseMap) = Index(_schema);
var (documents, reverseMap, abstractTypeMap) = Index(_schema);
var index = BM25Index.Build(documents);

var rootTypeNames = new HashSet<string>(StringComparer.Ordinal)
Expand All @@ -218,6 +237,10 @@ private SearchData EnsureIndex()
kvp => kvp.Key,
kvp => kvp.Value.ToArray(),
StringComparer.Ordinal),
abstractTypeMap.ToFrozenDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToArray(),
StringComparer.Ordinal),
rootTypeNames.ToFrozenSet(StringComparer.Ordinal));

return _searchData;
Expand Down Expand Up @@ -261,12 +284,15 @@ private static int DecodeCursor(string cursor, int resultCount)
private sealed class SearchData(
BM25Index index,
FrozenDictionary<string, SchemaCoordinate[]> reverseMap,
FrozenDictionary<string, string[]> abstractTypeMap,
FrozenSet<string> rootTypeNames)
{
public BM25Index Index { get; } = index;

public FrozenDictionary<string, SchemaCoordinate[]> ReverseMap { get; } = reverseMap;

public FrozenDictionary<string, string[]> AbstractTypeMap { get; } = abstractTypeMap;

public FrozenSet<string> RootTypeNames { get; } = rootTypeNames;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static SchemaIndexResult Index(ISchemaDefinition schema)
{
var documents = new List<BM25Document>();
var reverseMap = new Dictionary<string, List<SchemaCoordinate>>(StringComparer.Ordinal);
var abstractTypeMap = new Dictionary<string, List<string>>(StringComparer.Ordinal);

foreach (var type in schema.Types)
{
Expand All @@ -38,6 +39,11 @@ public static SchemaIndexResult Index(ISchemaDefinition schema)
{
case IComplexTypeDefinition complexType:
IndexComplexTypeFields(complexType, documents, reverseMap);
IndexImplementedInterfaces(complexType, abstractTypeMap);
break;

case IUnionTypeDefinition unionType:
IndexUnionMembers(unionType, abstractTypeMap);
break;

case IEnumTypeDefinition enumType:
Expand All @@ -53,7 +59,7 @@ public static SchemaIndexResult Index(ISchemaDefinition schema)
// Directives are not indexed for search — they have no fetch path.
// They remain accessible via __definitions coordinate lookup.

return new SchemaIndexResult(documents, reverseMap);
return new SchemaIndexResult(documents, reverseMap, abstractTypeMap);
}

private static void IndexComplexTypeFields(
Expand Down Expand Up @@ -86,6 +92,40 @@ private static void IndexComplexTypeFields(
}
}

private static void IndexImplementedInterfaces(
IComplexTypeDefinition complexType,
Dictionary<string, List<string>> abstractTypeMap)
{
foreach (var interfaceType in complexType.Implements)
{
AddAbstractType(abstractTypeMap, complexType.Name, interfaceType.Name);
}
}

private static void IndexUnionMembers(
IUnionTypeDefinition unionType,
Dictionary<string, List<string>> abstractTypeMap)
{
foreach (var memberType in unionType.Types)
{
AddAbstractType(abstractTypeMap, memberType.Name, unionType.Name);
}
}

private static void AddAbstractType(
Dictionary<string, List<string>> abstractTypeMap,
string typeName,
string abstractTypeName)
{
if (!abstractTypeMap.TryGetValue(typeName, out var abstractTypes))
{
abstractTypes = [];
abstractTypeMap[typeName] = abstractTypes;
}

abstractTypes.Add(abstractTypeName);
}

private static void IndexEnumValues(
IEnumTypeDefinition enumType,
List<BM25Document> documents)
Expand Down Expand Up @@ -121,10 +161,12 @@ private static string BuildText(string name, string? description)
}

/// <summary>
/// The result of indexing a schema, containing the indexed documents
/// and a reverse adjacency map for path-to-root traversal.
/// The result of indexing a schema, containing the indexed documents,
/// a reverse adjacency map for path-to-root traversal, and a map from each
/// type to the abstract types (interfaces or unions) through which it is reachable.
/// </summary>
internal readonly record struct SchemaIndexResult(
List<BM25Document> Documents,
Dictionary<string, List<SchemaCoordinate>> ReverseMap);
Dictionary<string, List<SchemaCoordinate>> ReverseMap,
Dictionary<string, List<string>> AbstractTypeMap);
}
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,10 @@ public async Task Search_Should_IncludePathsToRoot()
[
"Query.userByEmail",
"User.name"
],
[
"Query.users",
"User.name"
]
]
},
Expand Down Expand Up @@ -1305,6 +1309,10 @@ public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsFieldOnNestedTyp
[
"Query.userByEmail",
"User.email"
],
[
"Query.users",
"User.email"
]
]
}
Expand Down Expand Up @@ -1380,6 +1388,10 @@ public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsScalarType()
[
"Query.productSearch",
"Product.price"
],
[
"Query.orderById",
"Order.total"
]
]
}
Expand Down Expand Up @@ -1508,6 +1520,45 @@ public async Task PathsToRoot_Should_BeEmpty_When_CoordinateIsRootType()
""");
}

[Fact]
public async Task PathsToRoot_Should_TraverseInterface_When_TypeIsReachableOnlyViaInterface()
{
// arrange
// TV implements Product; only Query.products (returning [Product]) references it.
var executor = CreateAbstractTypeSchema().MakeExecutable();

// act
var result = await executor.ExecuteAsync(
"""
{
__search(query: "brandName", first: 1) {
coordinate
pathsToRoot
}
}
""");

// assert
result.MatchInlineSnapshot(
"""
{
"data": {
"__search": [
{
"coordinate": "TV.brandName",
"pathsToRoot": [
[
"Query.products",
"TV.brandName"
]
]
}
]
}
}
""");
}

private static Schema CreateSchema()
{
return SchemaBuilder.New()
Expand All @@ -1520,6 +1571,23 @@ private static Schema CreateSchema()
.Create();
}

private static Schema CreateAbstractTypeSchema()
{
return SchemaBuilder.New()
.AddQueryType(d =>
{
d.Name(OperationTypeNames.Query);
d.Field("products")
.Description("List all products")
.Type<ListType<ProductInterfaceType>>()
.Resolve(Array.Empty<object>());
})
.AddType<ProductInterfaceType>()
.AddType<TVType>()
.Use(next => next)
.Create();
}

private static Schema CreateSchemaWithSemanticIntrospectionDisabled()
{
return SchemaBuilder.New()
Expand Down Expand Up @@ -1612,6 +1680,31 @@ protected override void Configure(IObjectTypeDescriptor<Product> descriptor)
}
}

private sealed class ProductInterfaceType : InterfaceType
{
protected override void Configure(IInterfaceTypeDescriptor descriptor)
{
descriptor.Name("Product");
descriptor.Description("A product available for purchase");
descriptor.Field("id").Type<NonNullType<IdType>>();
}
}

private sealed class TVType : ObjectType
{
protected override void Configure(IObjectTypeDescriptor descriptor)
{
descriptor.Name("TV");
descriptor.Description("A television product");
descriptor.Implements<ProductInterfaceType>();
descriptor.Field("id").Type<NonNullType<IdType>>().Resolve("1");
descriptor.Field("brandName")
.Description("The manufacturer brand name")
.Type<StringType>()
.Resolve("Acme");
}
}

private record Order(string Id, decimal Total, string Status);

private sealed class OrderType : ObjectType<Order>
Expand Down
Loading
Loading