Skip to content

Commit 51f672f

Browse files
authored
[MCP] Added description property to entities and GraphQL Schema. (#2861)
## Why make this change? - Adds support for entity-level descriptions in Data API Builder GraphQL schema. - This feature request aims to surface entity descriptions from the config file in the generated GraphQL schema, improving API documentation and discoverability. - See related discussion: [#2834] ## What is this change? - Adds a `description` property to the entity model and ensures it is deserialized from the config. - Updates the GraphQL schema generator and converter to include entity descriptions as comments in the SDL and as HotChocolate type descriptions. - Adds unit tests to verify that entity descriptions appear in the generated schema. ## How was this tested? - [x] Unit Tests: Added tests to check for presence of entity description in generated GraphQL schema. - [x] Manual verification: Ran GraphQL introspection queries and checked schema SDL output. ## Sample Request(s) **GraphQL Introspection Query:** ```graphql { __type(name: "Todo") { name description } } ``` **Sample Query Response:** ``` { "data": { "__type": { "name": "Todo", "description": "Represents a todo item in the system" } } } ``` **Sample SDL output:** ``` """Represents a todo item in the system""" type Todo { ... } ```
1 parent 271cbf4 commit 51f672f

5 files changed

Lines changed: 159 additions & 5 deletions

File tree

schemas/dab.draft.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,10 @@
640640
"type": "object",
641641
"additionalProperties": false,
642642
"properties": {
643+
"description": {
644+
"type": "string",
645+
"description": "Optional description for the entity. Will be surfaced in generated API documentation and GraphQL schema as comments."
646+
},
643647
"health": {
644648
"description": "Health check configuration for entity",
645649
"type": [ "object", "null" ],

src/Config/ObjectModel/Entity.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ namespace Azure.DataApiBuilder.Config.ObjectModel;
2323
/// how long that response should be valid in the cache.</param>
2424
/// <param name="Health">Defines whether to enable comprehensive health check for the entity
2525
/// and how many rows to return in query and under what threshold-ms.</param>
26+
/// <param name="Description">Optional description for the entity. Used for API documentation and GraphQL schema comments.</param>
2627
public record Entity
2728
{
2829
public const string PROPERTY_PATH = "path";
2930
public const string PROPERTY_METHODS = "methods";
31+
public string? Description { get; init; }
3032
public EntitySource Source { get; init; }
3133
public EntityGraphQLOptions GraphQL { get; init; }
3234
public EntityRestOptions Rest { get; init; }
@@ -50,7 +52,8 @@ public Entity(
5052
Dictionary<string, EntityRelationship>? Relationships,
5153
EntityCacheOptions? Cache = null,
5254
bool IsLinkingEntity = false,
53-
EntityHealthCheckConfig? Health = null)
55+
EntityHealthCheckConfig? Health = null,
56+
string? Description = null)
5457
{
5558
this.Health = Health;
5659
this.Source = Source;
@@ -61,6 +64,7 @@ public Entity(
6164
this.Relationships = Relationships;
6265
this.Cache = Cache;
6366
this.IsLinkingEntity = IsLinkingEntity;
67+
this.Description = Description;
6468
}
6569

6670
/// <summary>

src/Core/Generator/SchemaGenerator.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ internal class SchemaGenerator
2828

2929
// List of JSON documents to process.
3030
private List<JsonDocument> _data;
31+
3132
// Name of the Azure Cosmos DB container from which the JSON data is obtained.
3233
private string _containerName;
34+
3335
// Dictionary mapping plural entity names to singular names based on the provided configuration.
3436
private Dictionary<string, string> _entityAndSingularNameMapping = new();
3537

38+
// Entities from config for description lookup
39+
private IReadOnlyDictionary<string, Entity>? _entities;
40+
3641
/// <summary>
3742
/// Initializes a new instance of the <see cref="SchemaGenerator"/> class.
3843
/// </summary>
@@ -57,6 +62,9 @@ private SchemaGenerator(List<JsonDocument> data, string containerName, RuntimeCo
5762
{
5863
_entityAndSingularNameMapping.Add(item.Value.GraphQL.Singular.Pascalize(), item.Key);
5964
}
65+
66+
// Convert RuntimeEntities to Dictionary for description lookup
67+
_entities = config.Entities.ToDictionary(x => x.Key, x => x.Value);
6068
}
6169
}
6270

@@ -129,6 +137,22 @@ private string GenerateGQLSchema()
129137
// Determine if the entity is the root entity.
130138
bool isRoot = entity.Key == _containerName.Pascalize();
131139

140+
// Get description from config if available
141+
string? description = null;
142+
if (_entityAndSingularNameMapping.ContainsKey(entity.Key) && _entities != null)
143+
{
144+
string configEntityName = _entityAndSingularNameMapping[entity.Key];
145+
if (_entities.ContainsKey(configEntityName))
146+
{
147+
description = _entities[configEntityName].Description;
148+
}
149+
}
150+
151+
if (!string.IsNullOrWhiteSpace(description))
152+
{
153+
sb.AppendLine($"\"\"\"{description}\"\"\"");
154+
}
155+
132156
sb.Append($"type {entity.Key} ");
133157

134158
// Append model directive if applicable.

src/Service.GraphQLBuilder/Sql/SchemaConverter.cs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ public static ObjectTypeDefinitionNode GenerateObjectTypeDefinitionForDatabaseOb
7878
subStatusCode: DataApiBuilderException.SubStatusCodes.NotSupported);
7979
}
8080

81+
StringValueNode? descriptionNode = null;
82+
if (!string.IsNullOrWhiteSpace(configEntity.Description))
83+
{
84+
descriptionNode = new StringValueNode(configEntity.Description);
85+
}
86+
87+
// Set the description node if available
88+
if (descriptionNode != null)
89+
{
90+
objectDefinitionNode = objectDefinitionNode.WithDescription(descriptionNode);
91+
}
92+
8193
return objectDefinitionNode;
8294
}
8395

@@ -122,14 +134,20 @@ private static ObjectTypeDefinitionNode CreateObjectTypeDefinitionForStoredProce
122134
}
123135
}
124136

137+
StringValueNode? descriptionNode = null;
138+
if (!string.IsNullOrWhiteSpace(configEntity.Description))
139+
{
140+
descriptionNode = new StringValueNode(configEntity.Description);
141+
}
142+
125143
// Top-level object type definition name should be singular.
126144
// The singularPlural.Singular value is used, and if not configured,
127145
// the top-level entity name value is used. No singularization occurs
128146
// if the top-level entity name is already plural.
129147
return new ObjectTypeDefinitionNode(
130148
location: null,
131149
name: new(value: GetDefinedSingularName(entityName, configEntity)),
132-
description: null,
150+
description: descriptionNode,
133151
directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity),
134152
new List<NamedTypeNode>(),
135153
fields.Values.ToImmutableList());
@@ -213,14 +231,20 @@ private static ObjectTypeDefinitionNode CreateObjectTypeDefinitionForTableOrView
213231
}
214232
}
215233

234+
StringValueNode? descriptionNode = null;
235+
if (!string.IsNullOrWhiteSpace(configEntity.Description))
236+
{
237+
descriptionNode = new StringValueNode(configEntity.Description);
238+
}
239+
216240
// Top-level object type definition name should be singular.
217241
// The singularPlural.Singular value is used, and if not configured,
218242
// the top-level entity name value is used. No singularization occurs
219243
// if the top-level entity name is already plural.
220244
return new ObjectTypeDefinitionNode(
221245
location: null,
222246
name: new(value: GetDefinedSingularName(entityName, configEntity)),
223-
description: null,
247+
description: descriptionNode,
224248
directives: GenerateObjectTypeDirectivesForEntity(entityName, configEntity, rolesAllowedForEntity),
225249
new List<NamedTypeNode>(),
226250
fieldDefinitionNodes.Values.ToImmutableList());
@@ -580,8 +604,7 @@ private static bool FindNullabilityOfRelationship(
580604
bool isNullableRelationship = false;
581605
SourceDefinition sourceDefinition = databaseObject.SourceDefinition;
582606
if (// Retrieve all the relationship information for the source entity which is backed by this table definition
583-
sourceDefinition.SourceEntityRelationshipMap.TryGetValue(entityName, out RelationshipMetadata? relationshipInfo)
584-
&&
607+
sourceDefinition.SourceEntityRelationshipMap.TryGetValue(entityName, out RelationshipMetadata? relationshipInfo) &&
585608
// From the relationship information, obtain the foreign key definition for the given target entity
586609
relationshipInfo.TargetEntityToFkDefinitionMap.TryGetValue(targetEntityName,
587610
out List<ForeignKeyDefinition>? listOfForeignKeys))

src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,105 @@ public override async Task TestNoAggregationOptionsForTableWithoutNumericFields(
779779
await base.TestNoAggregationOptionsForTableWithoutNumericFields();
780780
}
781781

782+
/// <summary>
783+
/// Tests that the entity description is present as a GraphQL comment in the generated schema for MSSQL.
784+
/// </summary>
785+
[TestMethod]
786+
public void TestEntityDescriptionInGraphQLSchema()
787+
{
788+
Entity entity = CreateEntityWithDescription("This is a test entity description for MSSQL.");
789+
RuntimeConfig config = CreateRuntimeConfig(entity);
790+
List<JsonDocument> jsonArray = [
791+
JsonDocument.Parse("{ \"id\": 1, \"name\": \"Test\" }")
792+
];
793+
794+
string actualSchema = Core.Generator.SchemaGenerator.Generate(jsonArray, "TestEntity", config);
795+
string expectedComment = "\"\"\"This is a test entity description for MSSQL.\"\"\"";
796+
Assert.IsTrue(actualSchema.Contains(expectedComment, StringComparison.Ordinal), "Entity description should be present as a GraphQL comment for MSSQL.");
797+
}
798+
799+
/// <summary>
800+
/// Description = null should not emit GraphQL description block.
801+
/// </summary>
802+
[TestMethod]
803+
public void TestEntityDescription_Null_NotInGraphQLSchema()
804+
{
805+
Entity entity = CreateEntityWithDescription(null);
806+
RuntimeConfig config = CreateRuntimeConfig(entity);
807+
string schema = Core.Generator.SchemaGenerator.Generate(
808+
[JsonDocument.Parse("{\"id\":1}")],
809+
"TestEntity",
810+
config);
811+
812+
Assert.IsFalse(schema.Contains("Test entity description null", StringComparison.Ordinal), "Null description must not appear in schema.");
813+
Assert.IsTrue(schema.Contains("type TestEntity", StringComparison.Ordinal), "Type definition should still exist.");
814+
}
815+
816+
/// <summary>
817+
/// Description = "" (empty) should not emit GraphQL description block.
818+
/// </summary>
819+
[TestMethod]
820+
public void TestEntityDescription_Empty_NotInGraphQLSchema()
821+
{
822+
Entity entity = CreateEntityWithDescription(string.Empty);
823+
RuntimeConfig config = CreateRuntimeConfig(entity);
824+
string schema = Core.Generator.SchemaGenerator.Generate(
825+
[JsonDocument.Parse("{\"id\":1}")],
826+
"TestEntity",
827+
config);
828+
829+
Assert.IsFalse(schema.Contains("\"\"\"\"\"\"", StringComparison.Ordinal), "Empty description triple quotes should not be emitted.");
830+
Assert.IsTrue(schema.Contains("type TestEntity", StringComparison.Ordinal), "Type definition should still exist.");
831+
}
832+
833+
/// <summary>
834+
/// Description = whitespace should not emit GraphQL description block.
835+
/// </summary>
836+
[TestMethod]
837+
public void TestEntityDescription_Whitespace_NotInGraphQLSchema()
838+
{
839+
Entity entity = CreateEntityWithDescription(" \t ");
840+
RuntimeConfig config = CreateRuntimeConfig(entity);
841+
string schema = Core.Generator.SchemaGenerator.Generate(
842+
[JsonDocument.Parse("{\"id\":1}")],
843+
"TestEntity",
844+
config);
845+
846+
Assert.IsFalse(schema.Contains("\"\"\"", StringComparison.Ordinal), "Whitespace-only description should not produce a GraphQL description block.");
847+
Assert.IsTrue(schema.Contains("type TestEntity", StringComparison.Ordinal), "Type definition should still exist.");
848+
}
849+
850+
private static Entity CreateEntityWithDescription(string description)
851+
{
852+
EntitySource source = new("TestTable", EntitySourceType.Table, null, null);
853+
EntityGraphQLOptions gqlOptions = new("TestEntity", "TestEntities", true);
854+
EntityRestOptions restOptions = new(null, "/test", true);
855+
return new(
856+
source,
857+
gqlOptions,
858+
restOptions,
859+
[],
860+
null,
861+
null,
862+
null,
863+
false,
864+
null,
865+
Description: description
866+
);
867+
}
868+
869+
private static RuntimeConfig CreateRuntimeConfig(Entity entity)
870+
{
871+
Dictionary<string, Entity> entityDict = new() { { "TestEntity", entity } };
872+
RuntimeEntities entities = new(entityDict);
873+
return new(
874+
"",
875+
new DataSource(DatabaseType.MSSQL, "", null),
876+
entities,
877+
null
878+
);
879+
}
880+
782881
#endregion
783882
}
784883
}

0 commit comments

Comments
 (0)