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 @@ -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<string> pointedAtPaths = new HashSet<string>(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<string, JsonNode> defs = new Dictionary<string, JsonNode>(
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();
Expand Down Expand Up @@ -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<string, IOpenApiSchema>(
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;
}
}

Expand All @@ -101,6 +117,7 @@ CancellationToken cancellationToken
private static void CollectDefinitions(
JsonObject node,
string prefix,
HashSet<string> pointedAtPaths,
Dictionary<string, JsonNode> result
)
{
Expand All @@ -112,17 +129,64 @@ Dictionary<string, JsonNode> 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<string> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OpenApiDocument> 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<string> referenced = ComponentReference()
.Matches(serialized)
.Select(x => x.Groups["name"].Value);

Assert.Empty(referenced.Except(document.Components!.Schemas!.Keys));
}

[GeneratedRegex(""""\$ref":\s*"#/components/schemas/(?<name>[^"]+)"""")]
private static partial Regex ComponentReference();
}