diff --git a/src/PureQL.CSharp.Model.OpenAPI.Schema/PureQLQueryDocumentTransformer.cs b/src/PureQL.CSharp.Model.OpenAPI.Schema/PureQLQueryDocumentTransformer.cs index 90e7e2e..9c2d5bc 100644 --- a/src/PureQL.CSharp.Model.OpenAPI.Schema/PureQLQueryDocumentTransformer.cs +++ b/src/PureQL.CSharp.Model.OpenAPI.Schema/PureQLQueryDocumentTransformer.cs @@ -46,12 +46,22 @@ CancellationToken cancellationToken { JsonObject specNode = JsonNode.Parse(_schema)!.AsObject(); + // Every "#/definitions/..." pointer the specification actually uses. A node that + // owns one of these as a child is a grouping node, never a schema of its own. + HashSet pointedAtPaths = new HashSet(StringComparer.Ordinal); + CollectDefinitionPointers(specNode, pointedAtPaths); + // Collect leaf schemas from the nested definitions tree. // Nested path "aggregates/date/average_date" → flat name "aggregates_date_average_date". Dictionary defs = new Dictionary( StringComparer.Ordinal ); - CollectDefinitions(specNode["definitions"]?.AsObject() ?? [], string.Empty, defs); + CollectDefinitions( + specNode["definitions"]?.AsObject() ?? [], + string.Empty, + pointedAtPaths, + defs + ); // Build root query schema node (strip "definitions" and "$schema" metadata). JsonObject rootNode = specNode.DeepClone().AsObject(); @@ -83,13 +93,19 @@ CancellationToken cancellationToken document.Components ??= new OpenApiComponents(); + // A document that reached the transformer without any generated schema still has a + // null Schemas dictionary, which the assignment below would throw on. + document.Components.Schemas ??= new Dictionary( + StringComparer.Ordinal + ); + // Transfer all PureQL schemas into the real document, replacing the // auto-generated (incorrect) Query schema along the way. if (parseResult.Document?.Components?.Schemas is { } pureqlSchemas) { foreach ((string name, IOpenApiSchema schema) in pureqlSchemas) { - document.Components.Schemas![name] = schema; + document.Components.Schemas[name] = schema; } } @@ -101,6 +117,7 @@ CancellationToken cancellationToken private static void CollectDefinitions( JsonObject node, string prefix, + HashSet pointedAtPaths, Dictionary result ) { @@ -112,17 +129,64 @@ Dictionary result continue; } - if (child.Any(p => SchemaKeywords.Contains(p.Key))) + // Checked before the keyword heuristic below, because grouping node names share + // a namespace with JSON Schema keywords: "booleanOperations" groups "and", "or" + // and "not", and "not" alone would make the heuristic mistake the whole group + // for a schema, dropping its three members and leaving dangling $refs behind. + if (child.Any(p => pointedAtPaths.Contains($"{path}/{p.Key}"))) + { + CollectDefinitions(child, path, pointedAtPaths, result); + } + else if (child.Any(p => SchemaKeywords.Contains(p.Key))) { result[path] = child; } else { - CollectDefinitions(child, path, result); + CollectDefinitions(child, path, pointedAtPaths, result); } } } + // Recursively collects the targets of every "#/definitions/..." pointer in the document, + // as paths relative to "definitions" ("booleanOperations/and", "scalars/booleanScalar"). + private static void CollectDefinitionPointers(JsonNode? node, HashSet result) + { + switch (node) + { + case JsonObject obj: + if ( + obj.TryGetPropertyValue("$ref", out JsonNode? refNode) + && refNode is JsonValue refVal + && refVal.TryGetValue(out string? refStr) + && refStr.StartsWith( + DefinitionsPointerPrefix, + StringComparison.Ordinal + ) + ) + { + _ = result.Add(refStr[DefinitionsPointerPrefix.Length..]); + } + + foreach ((string _, JsonNode? value) in obj) + { + CollectDefinitionPointers(value, result); + } + + break; + + case JsonArray arr: + foreach (JsonNode? item in arr) + { + CollectDefinitionPointers(item, result); + } + + break; + default: + break; + } + } + // Recursively rewrites "$ref" values from "#/definitions/X/Y/Z" // to "#/components/schemas/X_Y_Z" in place. private static void RewriteRefs(JsonNode? node) diff --git a/src/Tests/PureQL.CSharp.Model.OpenAPI.Schema.Tests/PureQLQueryDocumentTransformerTests.cs b/src/Tests/PureQL.CSharp.Model.OpenAPI.Schema.Tests/PureQLQueryDocumentTransformerTests.cs new file mode 100644 index 0000000..6b33aab --- /dev/null +++ b/src/Tests/PureQL.CSharp.Model.OpenAPI.Schema.Tests/PureQLQueryDocumentTransformerTests.cs @@ -0,0 +1,87 @@ +using System.Text.RegularExpressions; +using Microsoft.OpenApi; + +namespace PureQL.CSharp.Model.OpenAPI.Schema.Tests; + +public sealed partial record PureQLQueryDocumentTransformerTests +{ + // "booleanOperations" groups "and", "or" and "not". Its child named "not" collides with + // the JSON Schema keyword of the same name, which is what used to make the transformer + // mistake the whole group for a schema. + // lang=json,strict + private const string Specification = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["where"], + "properties": { + "where": { "$ref": "#/definitions/booleanReturning" } + }, + "definitions": { + "booleanReturning": { + "oneOf": [ + { "$ref": "#/definitions/booleanOperations/and" }, + { "$ref": "#/definitions/booleanOperations/or" }, + { "$ref": "#/definitions/booleanOperations/not" } + ] + }, + "booleanOperations": { + "and": { "type": "object", "properties": { "operator": { "const": "and" } } }, + "or": { "type": "object", "properties": { "operator": { "const": "or" } } }, + "not": { "type": "object", "properties": { "operator": { "const": "not" } } } + } + } + } + """; + + private static async Task TransformedDocument() + { + OpenApiDocument document = new OpenApiDocument(); + + await new PureQLQueryDocumentTransformer(Specification).TransformAsync( + document, + null!, + CancellationToken.None + ); + + return document; + } + + [Theory] + [InlineData("booleanOperations_and")] + [InlineData("booleanOperations_or")] + [InlineData("booleanOperations_not")] + public async Task EmitsMembersOfGroupNamedLikeSchemaKeyword(string name) + { + OpenApiDocument document = await TransformedDocument(); + + Assert.Contains(name, document.Components!.Schemas!.Keys); + } + + [Fact] + public async Task DoesNotEmitGroupingNodeAsSchema() + { + OpenApiDocument document = await TransformedDocument(); + + Assert.DoesNotContain("booleanOperations", document.Components!.Schemas!.Keys); + } + + [Fact] + public async Task LeavesNoDanglingComponentReferences() + { + OpenApiDocument document = await TransformedDocument(); + + string serialized = await document.SerializeAsJsonAsync( + OpenApiSpecVersion.OpenApi3_1 + ); + + IEnumerable referenced = ComponentReference() + .Matches(serialized) + .Select(x => x.Groups["name"].Value); + + Assert.Empty(referenced.Except(document.Components!.Schemas!.Keys)); + } + + [GeneratedRegex(""""\$ref":\s*"#/components/schemas/(?[^"]+)"""")] + private static partial Regex ComponentReference(); +}