From 62f401522d17e6ec567357f07e1ebcaa1186ecdc Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 09:57:21 +0200 Subject: [PATCH 1/8] Add JSON converter for `ApplicableTo` and corresponding unit tests --- .../AppliesTo/ApplicableTo.cs | 1 + .../AppliesTo/ApplicableToJsonConverter.cs | 276 ++++++++++++ .../ApplicableToJsonConverterTests.cs | 414 ++++++++++++++++++ 3 files changed, 691 insertions(+) create mode 100644 src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs create mode 100644 tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterTests.cs diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs index 2aba29981..35f64d124 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs @@ -34,6 +34,7 @@ public interface IApplicableToElement } [YamlSerializable] +[JsonConverter(typeof(ApplicableToJsonConverter))] public record ApplicableTo { [YamlMember(Alias = "stack")] diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs new file mode 100644 index 000000000..df60b9015 --- /dev/null +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs @@ -0,0 +1,276 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using YamlDotNet.Serialization; + +namespace Elastic.Documentation.AppliesTo; + +/// +/// JSON converter for ApplicableTo that serializes to a flat array of objects with: +/// - type: stack, deployment, serverless, or product +/// - sub-type: the property name (e.g., "self", "ece", "elasticsearch", "ecctl") +/// - lifecycle: the lifecycle value (if applicable) +/// - version: the version value (if applicable) +/// +public class ApplicableToJsonConverter : JsonConverter +{ + public override ApplicableTo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Expected array"); + + var result = new ApplicableTo(); + var deploymentProps = new Dictionary>(); + var serverlessProps = new Dictionary>(); + var productProps = new Dictionary>(); + var stackItems = new List(); + var productItems = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + break; + + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Expected object"); + + string? type = null; + string? subType = null; + var lifecycle = ProductLifecycle.GenerallyAvailable; + SemVersion? version = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException("Expected property name"); + + var propertyName = reader.GetString(); + _ = reader.Read(); + + switch (propertyName) + { + case "type": + type = reader.GetString(); + break; + case "sub-type": + subType = reader.GetString(); + break; + case "lifecycle": + var lifecycleStr = reader.GetString(); + if (lifecycleStr != null) + lifecycle = ParseLifecycle(lifecycleStr); + break; + case "version": + var versionStr = reader.GetString(); + if (versionStr != null && SemVersionConverter.TryParse(versionStr, out var v)) + version = v; + else if (versionStr == "all" || string.IsNullOrEmpty(versionStr)) + version = AllVersions.Instance; + break; + } + } + + if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(subType)) + throw new JsonException("Missing type or sub-type"); + + var applicability = new Applicability { Lifecycle = lifecycle, Version = version }; + + switch (type) + { + case "stack": + stackItems.Add(applicability); + break; + case "deployment": + if (!deploymentProps.ContainsKey(subType)) + deploymentProps[subType] = []; + deploymentProps[subType].Add(applicability); + break; + case "serverless": + if (!serverlessProps.ContainsKey(subType)) + serverlessProps[subType] = []; + serverlessProps[subType].Add(applicability); + break; + case "product" when subType == "product": + productItems.Add(applicability); + break; + case "product": + if (!productProps.ContainsKey(subType)) + productProps[subType] = []; + productProps[subType].Add(applicability); + break; + } + } + + // Create Stack collection + if (stackItems.Count > 0) + result.Stack = new AppliesCollection(stackItems.ToArray()); + + // Create Product collection + if (productItems.Count > 0) + result.Product = new AppliesCollection(productItems.ToArray()); + + // Reconstruct DeploymentApplicability + if (deploymentProps.Count > 0) + { + result.Deployment = new DeploymentApplicability + { + Self = deploymentProps.TryGetValue("self", out var self) ? new AppliesCollection(self.ToArray()) : null, + Ece = deploymentProps.TryGetValue("ece", out var ece) ? new AppliesCollection(ece.ToArray()) : null, + Eck = deploymentProps.TryGetValue("eck", out var eck) ? new AppliesCollection(eck.ToArray()) : null, + Ess = deploymentProps.TryGetValue("ess", out var ess) ? new AppliesCollection(ess.ToArray()) : null + }; + } + + // Reconstruct ServerlessProjectApplicability + if (serverlessProps.Count > 0) + { + result.Serverless = new ServerlessProjectApplicability + { + Elasticsearch = serverlessProps.TryGetValue("elasticsearch", out var es) ? new AppliesCollection(es.ToArray()) : null, + Observability = serverlessProps.TryGetValue("observability", out var obs) ? new AppliesCollection(obs.ToArray()) : null, + Security = serverlessProps.TryGetValue("security", out var sec) ? new AppliesCollection(sec.ToArray()) : null + }; + } + + // Reconstruct ProductApplicability + if (productProps.Count > 0) + { + var productApplicability = new ProductApplicability(); + var productType = typeof(ProductApplicability); + + foreach (var (key, items) in productProps) + { + // Find the property by YamlMember alias + var property = productType.GetProperties() + .FirstOrDefault(p => p.GetCustomAttribute()?.Alias == key); + + property?.SetValue(productApplicability, new AppliesCollection(items.ToArray())); + } + + result.ProductApplicability = productApplicability; + } + + return result; + } + + public override void Write(Utf8JsonWriter writer, ApplicableTo value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + // Stack + if (value.Stack != null) + { + WriteApplicabilityEntries(writer, "stack", "stack", value.Stack); + } + + // Deployment + if (value.Deployment != null) + { + if (value.Deployment.Self != null) + WriteApplicabilityEntries(writer, "deployment", "self", value.Deployment.Self); + if (value.Deployment.Ece != null) + WriteApplicabilityEntries(writer, "deployment", "ece", value.Deployment.Ece); + if (value.Deployment.Eck != null) + WriteApplicabilityEntries(writer, "deployment", "eck", value.Deployment.Eck); + if (value.Deployment.Ess != null) + WriteApplicabilityEntries(writer, "deployment", "ess", value.Deployment.Ess); + } + + // Serverless + if (value.Serverless != null) + { + if (value.Serverless.Elasticsearch != null) + WriteApplicabilityEntries(writer, "serverless", "elasticsearch", value.Serverless.Elasticsearch); + if (value.Serverless.Observability != null) + WriteApplicabilityEntries(writer, "serverless", "observability", value.Serverless.Observability); + if (value.Serverless.Security != null) + WriteApplicabilityEntries(writer, "serverless", "security", value.Serverless.Security); + } + + // Product (simple) + if (value.Product != null) + { + WriteApplicabilityEntries(writer, "product", "product", value.Product); + } + + // ProductApplicability (specific products) + if (value.ProductApplicability != null) + { + var productType = typeof(ProductApplicability); + foreach (var property in productType.GetProperties()) + { + var yamlAlias = property.GetCustomAttribute()?.Alias; + if (yamlAlias != null) + { + var propertyValue = property.GetValue(value.ProductApplicability) as AppliesCollection; + if (propertyValue != null) + WriteApplicabilityEntries(writer, "product", yamlAlias, propertyValue); + } + } + } + + writer.WriteEndArray(); + } + + private static ProductLifecycle ParseLifecycle(string lifecycleStr) + { + return lifecycleStr.ToLowerInvariant() switch + { + "preview" => ProductLifecycle.TechnicalPreview, + "beta" => ProductLifecycle.Beta, + "ga" => ProductLifecycle.GenerallyAvailable, + "deprecated" => ProductLifecycle.Deprecated, + "removed" => ProductLifecycle.Removed, + "unavailable" => ProductLifecycle.Unavailable, + "development" => ProductLifecycle.Development, + "planned" => ProductLifecycle.Planned, + "discontinued" => ProductLifecycle.Discontinued, + _ => ProductLifecycle.GenerallyAvailable + }; + } + + private static void WriteApplicabilityEntries(Utf8JsonWriter writer, string type, string subType, AppliesCollection collection) + { + foreach (var applicability in collection) + { + writer.WriteStartObject(); + writer.WriteString("type", type); + writer.WriteString("sub-type", subType); + + // Write lifecycle + var lifecycleName = applicability.Lifecycle switch + { + ProductLifecycle.TechnicalPreview => "preview", + ProductLifecycle.Beta => "beta", + ProductLifecycle.GenerallyAvailable => "ga", + ProductLifecycle.Deprecated => "deprecated", + ProductLifecycle.Removed => "removed", + ProductLifecycle.Unavailable => "unavailable", + ProductLifecycle.Development => "development", + ProductLifecycle.Planned => "planned", + ProductLifecycle.Discontinued => "discontinued", + _ => "ga" + }; + writer.WriteString("lifecycle", lifecycleName); + + // Write version + var isAllVersions = applicability.Version is null || ReferenceEquals(applicability.Version, AllVersions.Instance); + if (!isAllVersions) + writer.WriteString("version", applicability.Version!.ToString()); + else + writer.WriteString("version", "all"); + + writer.WriteEndObject(); + } + } +} diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterTests.cs new file mode 100644 index 000000000..4c0ccbe15 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterTests.cs @@ -0,0 +1,414 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.AppliesTo; + +public class ApplicableToJsonConverterTests +{ + private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + + [Fact] + public void RoundTrip_Stack_Simple() + { + var original = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void RoundTrip_Stack_WithVersion() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = new SemVersion(8, 0, 0) }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(7, 17, 0) } + ]) + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void RoundTrip_Deployment_AllProperties() + { + var original = new ApplicableTo + { + Deployment = new DeploymentApplicability + { + Self = AppliesCollection.GenerallyAvailable, + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]), + Ess = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Deployment.Should().NotBeNull(); + deserialized.Deployment!.Self.Should().BeEquivalentTo(original.Deployment!.Self); + deserialized.Deployment.Ece.Should().BeEquivalentTo(original.Deployment.Ece); + deserialized.Deployment.Eck.Should().BeEquivalentTo(original.Deployment.Eck); + deserialized.Deployment.Ess.Should().BeEquivalentTo(original.Deployment.Ess); + } + + [Fact] + public void RoundTrip_Serverless_AllProperties() + { + var original = new ApplicableTo + { + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = AppliesCollection.GenerallyAvailable, + Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersions.Instance }]), + Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Serverless.Should().NotBeNull(); + deserialized.Serverless!.Elasticsearch.Should().BeEquivalentTo(original.Serverless!.Elasticsearch); + deserialized.Serverless.Observability.Should().BeEquivalentTo(original.Serverless.Observability); + deserialized.Serverless.Security.Should().BeEquivalentTo(original.Serverless.Security); + } + + [Fact] + public void RoundTrip_Product_Simple() + { + var original = new ApplicableTo + { + Product = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Product.Should().NotBeNull(); + deserialized.Product.Should().BeEquivalentTo(original.Product); + } + + [Fact] + public void RoundTrip_ProductApplicability_SingleProduct() + { + var original = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + } + + [Fact] + public void RoundTrip_ProductApplicability_MultipleProducts() + { + var original = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]), + EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + deserialized.ProductApplicability.Curator.Should().BeEquivalentTo(original.ProductApplicability.Curator); + deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet); + deserialized.ProductApplicability.EdotDotnet.Should().BeEquivalentTo(original.ProductApplicability.EdotDotnet); + } + + [Fact] + public void RoundTrip_AllProductApplicability_Properties() + { + var original = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), + ApmAgentAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]), + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]), + ApmAgentGo = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"2.0.0" }]), + ApmAgentIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.5.0" }]), + ApmAgentJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.30.0" }]), + ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + ApmAgentPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.8.0" }]), + ApmAgentPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"6.0.0" }]), + ApmAgentRuby = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"4.0.0" }]), + ApmAgentRumJs = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"5.0.0" }]), + EdotIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]), + EdotAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.8.0" }]), + EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]), + EdotJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.7.0" }]), + EdotNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.6.0" }]), + EdotPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.5.0" }]), + EdotPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.4.0" }]), + EdotCfAws = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.3.0" }]), + EdotCfAzure = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.2.0" }]), + EdotCollector = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + deserialized.ProductApplicability.Curator.Should().BeEquivalentTo(original.ProductApplicability.Curator); + deserialized.ProductApplicability.ApmAgentAndroid.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentAndroid); + deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet); + deserialized.ProductApplicability.ApmAgentGo.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentGo); + deserialized.ProductApplicability.ApmAgentIos.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentIos); + deserialized.ProductApplicability.ApmAgentJava.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentJava); + deserialized.ProductApplicability.ApmAgentNode.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentNode); + deserialized.ProductApplicability.ApmAgentPhp.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentPhp); + deserialized.ProductApplicability.ApmAgentPython.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentPython); + deserialized.ProductApplicability.ApmAgentRuby.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentRuby); + deserialized.ProductApplicability.ApmAgentRumJs.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentRumJs); + deserialized.ProductApplicability.EdotIos.Should().BeEquivalentTo(original.ProductApplicability.EdotIos); + deserialized.ProductApplicability.EdotAndroid.Should().BeEquivalentTo(original.ProductApplicability.EdotAndroid); + deserialized.ProductApplicability.EdotDotnet.Should().BeEquivalentTo(original.ProductApplicability.EdotDotnet); + deserialized.ProductApplicability.EdotJava.Should().BeEquivalentTo(original.ProductApplicability.EdotJava); + deserialized.ProductApplicability.EdotNode.Should().BeEquivalentTo(original.ProductApplicability.EdotNode); + deserialized.ProductApplicability.EdotPhp.Should().BeEquivalentTo(original.ProductApplicability.EdotPhp); + deserialized.ProductApplicability.EdotPython.Should().BeEquivalentTo(original.ProductApplicability.EdotPython); + deserialized.ProductApplicability.EdotCfAws.Should().BeEquivalentTo(original.ProductApplicability.EdotCfAws); + deserialized.ProductApplicability.EdotCfAzure.Should().BeEquivalentTo(original.ProductApplicability.EdotCfAzure); + deserialized.ProductApplicability.EdotCollector.Should().BeEquivalentTo(original.ProductApplicability.EdotCollector); + } + + [Fact] + public void RoundTrip_Complex_AllFieldsPopulated() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" } + ]), + Deployment = new DeploymentApplicability + { + Self = AppliesCollection.GenerallyAvailable, + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]), + Ess = AppliesCollection.GenerallyAvailable + }, + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = AppliesCollection.GenerallyAvailable, + Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersions.Instance }]), + Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + }, + Product = AppliesCollection.GenerallyAvailable, + ProductApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().BeEquivalentTo(original.Stack); + deserialized.Deployment.Should().NotBeNull(); + deserialized.Deployment!.Self.Should().BeEquivalentTo(original.Deployment!.Self); + deserialized.Deployment.Ece.Should().BeEquivalentTo(original.Deployment.Ece); + deserialized.Deployment.Eck.Should().BeEquivalentTo(original.Deployment.Eck); + deserialized.Deployment.Ess.Should().BeEquivalentTo(original.Deployment.Ess); + deserialized.Serverless.Should().NotBeNull(); + deserialized.Serverless!.Elasticsearch.Should().BeEquivalentTo(original.Serverless!.Elasticsearch); + deserialized.Serverless.Observability.Should().BeEquivalentTo(original.Serverless.Observability); + deserialized.Serverless.Security.Should().BeEquivalentTo(original.Serverless.Security); + deserialized.Product.Should().BeEquivalentTo(original.Product); + deserialized.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet); + } + + [Fact] + public void RoundTrip_AllLifecycles() + { + var lifecycles = Enum.GetValues(); + var applicabilities = lifecycles.Select(lc => + new Applicability { Lifecycle = lc, Version = (SemVersion)"1.0.0" } + ).ToArray(); + + var original = new ApplicableTo + { + Stack = new AppliesCollection(applicabilities) + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void RoundTrip_MultipleApplicabilitiesInCollection() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" }, + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"7.16.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"6.0.0" } + ]) + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack.Should().HaveCount(4); + deserialized.Stack.Should().BeEquivalentTo(original.Stack); + } + + [Fact] + public void Serialize_ValidatesJsonStructure() + { + var original = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable, + Deployment = new DeploymentApplicability + { + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.ValueKind.Should().Be(JsonValueKind.Array); + var array = root.EnumerateArray().ToList(); + + array.Should().HaveCount(2); // Stack + Deployment.Ece + + var stackEntry = array[0]; + stackEntry.GetProperty("type").GetString().Should().Be("stack"); + stackEntry.GetProperty("sub-type").GetString().Should().Be("stack"); + stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + stackEntry.GetProperty("version").GetString().Should().Be("all"); + + var deploymentEntry = array[1]; + deploymentEntry.GetProperty("type").GetString().Should().Be("deployment"); + deploymentEntry.GetProperty("sub-type").GetString().Should().Be("ece"); + deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); + deploymentEntry.GetProperty("version").GetString().Should().Be("3.0.0"); + } + + [Fact] + public void RoundTrip_EmptyApplicableTo() + { + var original = new ApplicableTo(); + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().BeNull(); + deserialized.Deployment.Should().BeNull(); + deserialized.Serverless.Should().BeNull(); + deserialized.Product.Should().BeNull(); + deserialized.ProductApplicability.Should().BeNull(); + } + + [Fact] + public void RoundTrip_Null_ReturnsNull() + { + ApplicableTo? original = null; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().BeNull(); + } + + [Fact] + public void RoundTrip_AllVersions_SerializesAsAll() + { + var original = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = AllVersions.Instance }]) + }; + + var json = JsonSerializer.Serialize(original, _options); + json.Should().Contain("\"version\": \"all\""); + + var deserialized = JsonSerializer.Deserialize(json, _options); + deserialized.Should().NotBeNull(); + deserialized!.Stack.Should().NotBeNull(); + deserialized.Stack!.First().Version.Should().Be(AllVersions.Instance); + } + + [Fact] + public void RoundTrip_ProductAndProductApplicability_BothPresent() + { + var original = new ApplicableTo + { + Product = AppliesCollection.GenerallyAvailable, + ProductApplicability = new ProductApplicability + { + Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Product.Should().BeEquivalentTo(original.Product); + deserialized.ProductApplicability.Should().NotBeNull(); + deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl); + } +} From ed9cc82bae80727fe5ee3422acfa56ed9778a83a Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 10:12:12 +0200 Subject: [PATCH 2/8] Split `ApplicableToJsonConverterTests` into focused `ApplicableToJsonConverterRoundTripTests`. --- .../AppliesTo/ApplicableToJsonConverter.cs | 9 +- ...pplicableToJsonConverterRoundTripTests.cs} | 40 +-- ...icableToJsonConverterSerializationTests.cs | 307 ++++++++++++++++++ 3 files changed, 312 insertions(+), 44 deletions(-) rename tests/Elastic.Markdown.Tests/AppliesTo/{ApplicableToJsonConverterTests.cs => ApplicableToJsonConverterRoundTripTests.cs} (92%) create mode 100644 tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs index df60b9015..7144a99ab 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs @@ -74,8 +74,6 @@ public class ApplicableToJsonConverter : JsonConverter var versionStr = reader.GetString(); if (versionStr != null && SemVersionConverter.TryParse(versionStr, out var v)) version = v; - else if (versionStr == "all" || string.IsNullOrEmpty(versionStr)) - version = AllVersions.Instance; break; } } @@ -264,11 +262,8 @@ private static void WriteApplicabilityEntries(Utf8JsonWriter writer, string type writer.WriteString("lifecycle", lifecycleName); // Write version - var isAllVersions = applicability.Version is null || ReferenceEquals(applicability.Version, AllVersions.Instance); - if (!isAllVersions) - writer.WriteString("version", applicability.Version!.ToString()); - else - writer.WriteString("version", "all"); + if (applicability.Version is not null) + writer.WriteString("version", applicability.Version.ToString()); writer.WriteEndObject(); } diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs similarity index 92% rename from tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterTests.cs rename to tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs index 4c0ccbe15..6ada33838 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs @@ -9,7 +9,7 @@ namespace Elastic.Markdown.Tests.AppliesTo; -public class ApplicableToJsonConverterTests +public class ApplicableToJsonConverterRoundTripTests { private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; @@ -313,40 +313,6 @@ public void RoundTrip_MultipleApplicabilitiesInCollection() deserialized.Stack.Should().BeEquivalentTo(original.Stack); } - [Fact] - public void Serialize_ValidatesJsonStructure() - { - var original = new ApplicableTo - { - Stack = AppliesCollection.GenerallyAvailable, - Deployment = new DeploymentApplicability - { - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.0.0" }]) - } - }; - - var json = JsonSerializer.Serialize(original, _options); - var jsonDoc = JsonDocument.Parse(json); - var root = jsonDoc.RootElement; - - root.ValueKind.Should().Be(JsonValueKind.Array); - var array = root.EnumerateArray().ToList(); - - array.Should().HaveCount(2); // Stack + Deployment.Ece - - var stackEntry = array[0]; - stackEntry.GetProperty("type").GetString().Should().Be("stack"); - stackEntry.GetProperty("sub-type").GetString().Should().Be("stack"); - stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); - stackEntry.GetProperty("version").GetString().Should().Be("all"); - - var deploymentEntry = array[1]; - deploymentEntry.GetProperty("type").GetString().Should().Be("deployment"); - deploymentEntry.GetProperty("sub-type").GetString().Should().Be("ece"); - deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); - deploymentEntry.GetProperty("version").GetString().Should().Be("3.0.0"); - } - [Fact] public void RoundTrip_EmptyApplicableTo() { @@ -375,7 +341,7 @@ public void RoundTrip_Null_ReturnsNull() } [Fact] - public void RoundTrip_AllVersions_SerializesAsAll() + public void RoundTrip_AllVersions_SerializesAsSemanticVersion() { var original = new ApplicableTo { @@ -383,7 +349,7 @@ public void RoundTrip_AllVersions_SerializesAsAll() }; var json = JsonSerializer.Serialize(original, _options); - json.Should().Contain("\"version\": \"all\""); + json.Should().Contain("\"version\": \"9999.9999.9999\""); var deserialized = JsonSerializer.Deserialize(json, _options); deserialized.Should().NotBeNull(); diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs new file mode 100644 index 000000000..76aa70355 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs @@ -0,0 +1,307 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.AppliesTo; + +public class ApplicableToJsonConverterSerializationTests +{ + private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + + [Fact] + public void Serialize_Stack_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be(""" +[ + { + "type": "stack", + "sub-type": "stack", + "lifecycle": "ga", + "version": "9999.9999.9999" + } +] +"""); + } + + [Fact] + public void Serialize_StackWithVersion_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"8.0.0" }]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be(""" +[ + { + "type": "stack", + "sub-type": "stack", + "lifecycle": "beta", + "version": "8.0.0" + } +] +"""); + } + + [Fact] + public void Serialize_MultipleApplicabilities_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" } + ]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be(""" +[ + { + "type": "stack", + "sub-type": "stack", + "lifecycle": "ga", + "version": "8.0.0" + }, + { + "type": "stack", + "sub-type": "stack", + "lifecycle": "beta", + "version": "7.17.0" + } +] +"""); + } + + [Fact] + public void Serialize_Deployment_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Deployment = new DeploymentApplicability + { + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + Ess = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be(""" +[ + { + "type": "deployment", + "sub-type": "ece", + "lifecycle": "ga", + "version": "3.0.0" + }, + { + "type": "deployment", + "sub-type": "ess", + "lifecycle": "ga", + "version": "9999.9999.9999" + } +] +"""); + } + + [Fact] + public void Serialize_Serverless_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]), + Security = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be(""" +[ + { + "type": "serverless", + "sub-type": "elasticsearch", + "lifecycle": "beta", + "version": "1.0.0" + }, + { + "type": "serverless", + "sub-type": "security", + "lifecycle": "ga", + "version": "9999.9999.9999" + } +] +"""); + } + + [Fact] + public void Serialize_Product_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.5.0" }]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be(""" +[ + { + "type": "product", + "sub-type": "product", + "lifecycle": "preview", + "version": "0.5.0" + } +] +"""); + } + + [Fact] + public void Serialize_ProductApplicability_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), + ApmAgentDotnet = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be(""" +[ + { + "type": "product", + "sub-type": "ecctl", + "lifecycle": "deprecated", + "version": "5.0.0" + }, + { + "type": "product", + "sub-type": "apm-agent-dotnet", + "lifecycle": "ga", + "version": "9999.9999.9999" + } +] +"""); + } + + [Fact] + public void Serialize_AllLifecycles_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"1.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Removed, Version = (SemVersion)"1.0.0" } + ]) + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Contain("\"lifecycle\": \"preview\""); + json.Should().Contain("\"lifecycle\": \"beta\""); + json.Should().Contain("\"lifecycle\": \"ga\""); + json.Should().Contain("\"lifecycle\": \"deprecated\""); + json.Should().Contain("\"lifecycle\": \"removed\""); + } + + [Fact] + public void Serialize_Complex_ProducesCorrectJson() + { + var applicableTo = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), + Deployment = new DeploymentApplicability + { + Ece = AppliesCollection.GenerallyAvailable + }, + Product = AppliesCollection.GenerallyAvailable + }; + + var json = JsonSerializer.Serialize(applicableTo, _options); + + // Verify it's an array with 3 items + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetArrayLength().Should().Be(3); + + // Verify each type is present + json.Should().Contain("\"type\": \"stack\""); + json.Should().Contain("\"type\": \"deployment\""); + json.Should().Contain("\"type\": \"product\""); + + // Verify sub-types + json.Should().Contain("\"sub-type\": \"stack\""); + json.Should().Contain("\"sub-type\": \"ece\""); + json.Should().Contain("\"sub-type\": \"product\""); + } + + [Fact] + public void Serialize_Empty_ProducesEmptyArray() + { + var applicableTo = new ApplicableTo(); + + var json = JsonSerializer.Serialize(applicableTo, _options); + + json.Should().Be("[]"); + } + + [Fact] + public void Serialize_ValidatesJsonStructure() + { + var original = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable, + Deployment = new DeploymentApplicability + { + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(original, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.ValueKind.Should().Be(JsonValueKind.Array); + var array = root.EnumerateArray().ToList(); + + array.Should().HaveCount(2); // Stack + Deployment.Ece + + var stackEntry = array[0]; + stackEntry.GetProperty("type").GetString().Should().Be("stack"); + stackEntry.GetProperty("sub-type").GetString().Should().Be("stack"); + stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + + var deploymentEntry = array[1]; + deploymentEntry.GetProperty("type").GetString().Should().Be("deployment"); + deploymentEntry.GetProperty("sub-type").GetString().Should().Be("ece"); + deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); + deploymentEntry.GetProperty("version").GetString().Should().Be("3.0.0"); + } +} From 8cb26a13e2361cc504c9674594722807edf6d6e2 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 10:18:05 +0200 Subject: [PATCH 3/8] Add `applies` property as nested field in Elasticsearch exporter mapping --- .../AppliesTo/ApplicableToJsonConverter.cs | 4 +-- .../Elasticsearch/ElasticsearchExporter.cs | 9 +++++ ...icableToJsonConverterSerializationTests.cs | 34 +++++++++---------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs index 7144a99ab..818b178ce 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs @@ -62,7 +62,7 @@ public class ApplicableToJsonConverter : JsonConverter case "type": type = reader.GetString(); break; - case "sub-type": + case "sub_type": subType = reader.GetString(); break; case "lifecycle": @@ -243,7 +243,7 @@ private static void WriteApplicabilityEntries(Utf8JsonWriter writer, string type { writer.WriteStartObject(); writer.WriteString("type", type); - writer.WriteString("sub-type", subType); + writer.WriteString("sub_type", subType); // Write lifecycle var lifecycleName = applicability.Lifecycle switch diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs index 199724929..b3fe4d4e9 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs @@ -193,6 +193,15 @@ protected static string CreateMapping(string? inferenceId) => "prefix": { "type": "text", "analyzer" : "hierarchy_analyzer" } } }, + "applies" : { + "type" : "nested" + "properties" : { + "type" : { "type" : "keyword" }, + "sub-type" : { "type" : "keyword" }, + "lifecycle" : { "type" : "keyword" }, + "version" : { "type" : "version" }, + } + }, "hash" : { "type" : "keyword" }, "title": { "type": "text", diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs index 76aa70355..687363a14 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs @@ -27,7 +27,7 @@ public void Serialize_Stack_ProducesCorrectJson() [ { "type": "stack", - "sub-type": "stack", + "sub_type": "stack", "lifecycle": "ga", "version": "9999.9999.9999" } @@ -49,7 +49,7 @@ public void Serialize_StackWithVersion_ProducesCorrectJson() [ { "type": "stack", - "sub-type": "stack", + "sub_type": "stack", "lifecycle": "beta", "version": "8.0.0" } @@ -75,13 +75,13 @@ public void Serialize_MultipleApplicabilities_ProducesCorrectJson() [ { "type": "stack", - "sub-type": "stack", + "sub_type": "stack", "lifecycle": "ga", "version": "8.0.0" }, { "type": "stack", - "sub-type": "stack", + "sub_type": "stack", "lifecycle": "beta", "version": "7.17.0" } @@ -107,13 +107,13 @@ public void Serialize_Deployment_ProducesCorrectJson() [ { "type": "deployment", - "sub-type": "ece", + "sub_type": "ece", "lifecycle": "ga", "version": "3.0.0" }, { "type": "deployment", - "sub-type": "ess", + "sub_type": "ess", "lifecycle": "ga", "version": "9999.9999.9999" } @@ -139,13 +139,13 @@ public void Serialize_Serverless_ProducesCorrectJson() [ { "type": "serverless", - "sub-type": "elasticsearch", + "sub_type": "elasticsearch", "lifecycle": "beta", "version": "1.0.0" }, { "type": "serverless", - "sub-type": "security", + "sub_type": "security", "lifecycle": "ga", "version": "9999.9999.9999" } @@ -167,7 +167,7 @@ public void Serialize_Product_ProducesCorrectJson() [ { "type": "product", - "sub-type": "product", + "sub_type": "product", "lifecycle": "preview", "version": "0.5.0" } @@ -193,13 +193,13 @@ public void Serialize_ProductApplicability_ProducesCorrectJson() [ { "type": "product", - "sub-type": "ecctl", + "sub_type": "ecctl", "lifecycle": "deprecated", "version": "5.0.0" }, { "type": "product", - "sub-type": "apm-agent-dotnet", + "sub_type": "apm-agent-dotnet", "lifecycle": "ga", "version": "9999.9999.9999" } @@ -255,10 +255,10 @@ public void Serialize_Complex_ProducesCorrectJson() json.Should().Contain("\"type\": \"deployment\""); json.Should().Contain("\"type\": \"product\""); - // Verify sub-types - json.Should().Contain("\"sub-type\": \"stack\""); - json.Should().Contain("\"sub-type\": \"ece\""); - json.Should().Contain("\"sub-type\": \"product\""); + // Verify sub_types + json.Should().Contain("\"sub_type\": \"stack\""); + json.Should().Contain("\"sub_type\": \"ece\""); + json.Should().Contain("\"sub_type\": \"product\""); } [Fact] @@ -294,13 +294,13 @@ public void Serialize_ValidatesJsonStructure() var stackEntry = array[0]; stackEntry.GetProperty("type").GetString().Should().Be("stack"); - stackEntry.GetProperty("sub-type").GetString().Should().Be("stack"); + stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); var deploymentEntry = array[1]; deploymentEntry.GetProperty("type").GetString().Should().Be("deployment"); - deploymentEntry.GetProperty("sub-type").GetString().Should().Be("ece"); + deploymentEntry.GetProperty("sub_type").GetString().Should().Be("ece"); deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); deploymentEntry.GetProperty("version").GetString().Should().Be("3.0.0"); } From 6ff258bb04cc58fb25baba51e972c43f072503a0 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 12:04:34 +0200 Subject: [PATCH 4/8] Add unit tests for `ApplicableTo` serialization and `ProductApplicability.ToString()` implementation --- .../AppliesTo/ApplicableTo.cs | 163 ++++++++ .../ProductApplicabilityToStringTests.cs | 112 ++++++ ...DocumentationDocumentSerializationTests.cs | 352 ++++++++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs create mode 100644 tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs index 35f64d124..978a1f1fe 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Collections; +using System.Text; using System.Text.Json.Serialization; using Elastic.Documentation.Diagnostics; using YamlDotNet.Serialization; @@ -62,6 +63,58 @@ public record ApplicableTo Deployment = DeploymentApplicability.All, Product = AppliesCollection.GenerallyAvailable }; + + public static ApplicableTo Default { get; } = new() + { + Stack = new AppliesCollection([new Applicability { Version = new SemVersion(9, 0, 0), Lifecycle = ProductLifecycle.GenerallyAvailable }]), + Serverless = ServerlessProjectApplicability.All + }; + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + if (Stack is not null) + { + _ = sb.Append("stack: ").Append(Stack); + hasContent = true; + } + + if (Deployment is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("deployment: ").Append(Deployment); + hasContent = true; + } + + if (Serverless is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("serverless: ").Append(Serverless); + hasContent = true; + } + + if (Product is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("product: ").Append(Product); + hasContent = true; + } + + if (ProductApplicability is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("products: ").Append(ProductApplicability); + } + + return sb.ToString(); + } } [YamlSerializable] @@ -86,6 +139,44 @@ public record DeploymentApplicability Ess = AppliesCollection.GenerallyAvailable, Self = AppliesCollection.GenerallyAvailable }; + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + if (Self is not null) + { + _ = sb.Append("self=").Append(Self); + hasContent = true; + } + + if (Ece is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("ece=").Append(Ece); + hasContent = true; + } + + if (Eck is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("eck=").Append(Eck); + hasContent = true; + } + + if (Ess is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("ess=").Append(Ess); + } + + return sb.ToString(); + } } [YamlSerializable] @@ -114,6 +205,36 @@ public record ServerlessProjectApplicability Observability = AppliesCollection.GenerallyAvailable, Security = AppliesCollection.GenerallyAvailable }; + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + if (Elasticsearch is not null) + { + _ = sb.Append("elasticsearch=").Append(Elasticsearch); + hasContent = true; + } + + if (Observability is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("observability=").Append(Observability); + hasContent = true; + } + + if (Security is not null) + { + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append("security=").Append(Security); + } + + return sb.ToString(); + } } [YamlSerializable] @@ -184,4 +305,46 @@ public record ProductApplicability [YamlMember(Alias = "edot-collector")] public AppliesCollection? EdotCollector { get; set; } + + /// + public override string ToString() + { + var sb = new StringBuilder(); + var hasContent = false; + + void AppendProduct(string name, AppliesCollection? value) + { + if (value is null) + return; + if (hasContent) + _ = sb.Append(", "); + _ = sb.Append(name).Append('=').Append(value); + hasContent = true; + } + + AppendProduct("ecctl", Ecctl); + AppendProduct("curator", Curator); + AppendProduct("apm-agent-android", ApmAgentAndroid); + AppendProduct("apm-agent-dotnet", ApmAgentDotnet); + AppendProduct("apm-agent-go", ApmAgentGo); + AppendProduct("apm-agent-ios", ApmAgentIos); + AppendProduct("apm-agent-java", ApmAgentJava); + AppendProduct("apm-agent-node", ApmAgentNode); + AppendProduct("apm-agent-php", ApmAgentPhp); + AppendProduct("apm-agent-python", ApmAgentPython); + AppendProduct("apm-agent-ruby", ApmAgentRuby); + AppendProduct("apm-agent-rum-js", ApmAgentRumJs); + AppendProduct("edot-ios", EdotIos); + AppendProduct("edot-android", EdotAndroid); + AppendProduct("edot-dotnet", EdotDotnet); + AppendProduct("edot-java", EdotJava); + AppendProduct("edot-node", EdotNode); + AppendProduct("edot-php", EdotPhp); + AppendProduct("edot-python", EdotPython); + AppendProduct("edot-cf-aws", EdotCfAws); + AppendProduct("edot-cf-azure", EdotCfAzure); + AppendProduct("edot-collector", EdotCollector); + + return sb.ToString(); + } } diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs new file mode 100644 index 000000000..2342522e9 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs @@ -0,0 +1,112 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Reflection; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using FluentAssertions; +using YamlDotNet.Serialization; + +namespace Elastic.Markdown.Tests.AppliesTo; + +public class ProductApplicabilityToStringTests +{ + [Fact] + public void ProductApplicability_ToString_IncludesAllProperties() + { + // Create a ProductApplicability with all properties set + var productApplicability = new ProductApplicability(); + var productType = typeof(ProductApplicability); + var properties = productType.GetProperties() + .Where(p => p.GetCustomAttribute() != null) + .ToList(); + + // Set all properties to a test value + var testValue = AppliesCollection.GenerallyAvailable; + foreach (var property in properties) + { + property.SetValue(productApplicability, testValue); + } + + // Get the ToString output + var result = productApplicability.ToString(); + + // Verify that each property's YAML alias appears in the output + foreach (var property in properties) + { + var yamlAlias = property.GetCustomAttribute()!.Alias; + result.Should().Contain($"{yamlAlias}=", + $"ToString should include the property {property.Name} with alias '{yamlAlias}'"); + } + + // Verify we have the expected number of properties + properties.Should().HaveCount(22, "ProductApplicability should have exactly 22 product properties"); + } + + [Fact] + public void ProductApplicability_ToString_WithSomeProperties_OnlyIncludesSetProperties() + { + var productApplicability = new ProductApplicability + { + ApmAgentDotnet = AppliesCollection.GenerallyAvailable, + Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(1, 0, 0) }]) + }; + + var result = productApplicability.ToString(); + + // Should include set properties + result.Should().Contain("apm-agent-dotnet="); + result.Should().Contain("ecctl="); + + // Should not include unset properties + result.Should().NotContain("apm-agent-node="); + result.Should().NotContain("curator="); + } + + [Fact] + public void ProductApplicability_ToString_Empty_ReturnsEmptyString() + { + var productApplicability = new ProductApplicability(); + + var result = productApplicability.ToString(); + + result.Should().Be(""); + } + + [Fact] + public void ProductApplicability_ToString_PropertyOrder_MatchesReflectionOrder() + { + // This test ensures that properties appear in the order they are defined + var productApplicability = new ProductApplicability + { + Ecctl = AppliesCollection.GenerallyAvailable, + Curator = AppliesCollection.GenerallyAvailable, + ApmAgentAndroid = AppliesCollection.GenerallyAvailable + }; + + var result = productApplicability.ToString(); + + // Get the properties in reflection order + var productType = typeof(ProductApplicability); + var properties = productType.GetProperties() + .Where(p => p.GetCustomAttribute() != null) + .Select(p => p.GetCustomAttribute()!.Alias) + .ToList(); + + // Find positions in the string + var positions = new Dictionary(); + foreach (var alias in new[] { "ecctl", "curator", "apm-agent-android" }) + { + var index = result.IndexOf($"{alias}=", StringComparison.Ordinal); + if (index >= 0) + positions[alias] = index; + } + + // Verify that the properties appear in the correct order + positions["ecctl"].Should().BeLessThan(positions["curator"], + "ecctl should appear before curator"); + positions["curator"].Should().BeLessThan(positions["apm-agent-android"], + "curator should appear before apm-agent-android"); + } +} diff --git a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs new file mode 100644 index 000000000..d77b7181d --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs @@ -0,0 +1,352 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using Elastic.Documentation.Search; +using Elastic.Documentation.Serialization; +using FluentAssertions; + +namespace Elastic.Markdown.Tests.Search; + +public class DocumentationDocumentSerializationTests +{ + private readonly JsonSerializerOptions _options = new(SourceGenerationContext.Default.Options); + + [Fact] + public void Serialize_DocumentWithStackAppliesTo_ProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/page", + Title = "Test Page", + Applies = new ApplicableTo + { + Stack = AppliesCollection.GenerallyAvailable + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + // Verify applies_to exists + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + appliesTo.ValueKind.Should().Be(JsonValueKind.Array); + + // Verify structure + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(1); + + var stackEntry = appliesArray[0]; + stackEntry.GetProperty("type").GetString().Should().Be("stack"); + stackEntry.GetProperty("sub_type").GetString().Should().Be("stack"); + stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + } + + [Fact] + public void Serialize_DocumentWithDeploymentAppliesTo_ProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/deployment", + Title = "Deployment Test", + Applies = new ApplicableTo + { + Deployment = new DeploymentApplicability + { + Ess = AppliesCollection.GenerallyAvailable, + Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.5.0" }]) + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(2); + + // Verify ESS entry + var essEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ess"); + essEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + essEntry.GetProperty("type").GetString().Should().Be("deployment"); + essEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + essEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999"); + + // Verify ECE entry + var eceEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ece"); + eceEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + eceEntry.GetProperty("type").GetString().Should().Be("deployment"); + eceEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); + eceEntry.GetProperty("version").GetString().Should().Be("3.5.0"); + } + + [Fact] + public void Serialize_DocumentWithServerlessAppliesTo_ProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/serverless", + Title = "Serverless Test", + Applies = new ApplicableTo + { + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), + Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"1.0.0" }]) + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(2); + + // Verify elasticsearch entry + var esEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "elasticsearch"); + esEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + esEntry.GetProperty("type").GetString().Should().Be("serverless"); + esEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + esEntry.GetProperty("version").GetString().Should().Be("8.0.0"); + + // Verify security entry + var secEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "security"); + secEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + secEntry.GetProperty("type").GetString().Should().Be("serverless"); + secEntry.GetProperty("lifecycle").GetString().Should().Be("preview"); + secEntry.GetProperty("version").GetString().Should().Be("1.0.0"); + } + + [Fact] + public void Serialize_DocumentWithProductAppliesTo_ProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/product", + Title = "Product Test", + Applies = new ApplicableTo + { + Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]) + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(1); + + var productEntry = appliesArray[0]; + productEntry.GetProperty("type").GetString().Should().Be("product"); + productEntry.GetProperty("sub_type").GetString().Should().Be("product"); + productEntry.GetProperty("lifecycle").GetString().Should().Be("beta"); + productEntry.GetProperty("version").GetString().Should().Be("2.0.0"); + } + + [Fact] + public void Serialize_DocumentWithProductApplicability_ProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/apm", + Title = "APM Test", + Applies = new ApplicableTo + { + ProductApplicability = new ProductApplicability + { + ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.5.0" }]), + ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"2.0.0" }]) + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(2); + + // Verify apm-agent-dotnet entry + var dotnetEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-dotnet"); + dotnetEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + dotnetEntry.GetProperty("type").GetString().Should().Be("product"); + dotnetEntry.GetProperty("lifecycle").GetString().Should().Be("ga"); + dotnetEntry.GetProperty("version").GetString().Should().Be("1.5.0"); + + // Verify apm-agent-node entry + var nodeEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-node"); + nodeEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined); + nodeEntry.GetProperty("type").GetString().Should().Be("product"); + nodeEntry.GetProperty("lifecycle").GetString().Should().Be("deprecated"); + nodeEntry.GetProperty("version").GetString().Should().Be("2.0.0"); + } + + [Fact] + public void Serialize_DocumentWithComplexAppliesTo_ProducesCorrectJson() + { + var doc = new DocumentationDocument + { + Url = "/test/complex", + Title = "Complex Test", + Applies = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), + Deployment = new DeploymentApplicability + { + Ess = AppliesCollection.GenerallyAvailable + }, + Serverless = new ServerlessProjectApplicability + { + Elasticsearch = AppliesCollection.GenerallyAvailable + } + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(3); + + // Verify we have all three types + appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "stack"); + appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "deployment"); + appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "serverless"); + } + + [Fact] + public void Serialize_DocumentWithNullAppliesTo_OmitsField() + { + var doc = new DocumentationDocument + { + Url = "/test/no-applies", + Title = "No Applies Test", + Applies = null + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + // With default JSON options, null values might be omitted or serialized as null + // Let's check both possibilities + if (root.TryGetProperty("applies_to", out var appliesTo)) + { + appliesTo.ValueKind.Should().Be(JsonValueKind.Null); + } + else + { + // Field is omitted, which is also acceptable + true.Should().BeTrue(); + } + } + + [Fact] + public void Serialize_DocumentWithEmptyAppliesTo_ProducesEmptyArray() + { + var doc = new DocumentationDocument + { + Url = "/test/empty-applies", + Title = "Empty Applies Test", + Applies = new ApplicableTo() + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + appliesTo.ValueKind.Should().Be(JsonValueKind.Array); + appliesTo.GetArrayLength().Should().Be(0); + } + + [Fact] + public void RoundTrip_DocumentWithAppliesTo_PreservesData() + { + var original = new DocumentationDocument + { + Url = "/test/roundtrip", + Title = "Round Trip Test", + Hash = "abc123", + BatchIndexDate = DateTimeOffset.Parse("2024-01-15T10:00:00Z"), + LastUpdated = DateTimeOffset.Parse("2024-01-15T09:00:00Z"), + Applies = new ApplicableTo + { + Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.5.0" }]), + Deployment = new DeploymentApplicability + { + Ess = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"8.6.0" }]) + } + }, + Headings = ["Introduction", "Getting Started"], + Links = ["/link1", "/link2"], + Body = "Test body content", + StrippedBody = "Test body content", + Description = "Test description" + }; + + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + deserialized.Should().NotBeNull(); + deserialized!.Url.Should().Be(original.Url); + deserialized.Title.Should().Be(original.Title); + deserialized.Applies.Should().NotBeNull(); + deserialized.Applies!.Stack.Should().BeEquivalentTo(original.Applies!.Stack); + deserialized.Applies.Deployment.Should().NotBeNull(); + deserialized.Applies.Deployment!.Ess.Should().BeEquivalentTo(original.Applies.Deployment!.Ess); + } + + [Fact] + public void Serialize_DocumentWithMultipleApplicabilitiesPerType_ProducesMultipleArrayEntries() + { + var doc = new DocumentationDocument + { + Url = "/test/multiple", + Title = "Multiple Test", + Applies = new ApplicableTo + { + Stack = new AppliesCollection( + [ + new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, + new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" }, + new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"7.0.0" } + ]) + } + }; + + var json = JsonSerializer.Serialize(doc, _options); + var jsonDoc = JsonDocument.Parse(json); + var root = jsonDoc.RootElement; + + root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); + var appliesArray = appliesTo.EnumerateArray().ToList(); + appliesArray.Should().HaveCount(3); + + // All should be stack type + appliesArray.Should().OnlyContain(e => e.GetProperty("type").GetString() == "stack"); + appliesArray.Should().OnlyContain(e => e.GetProperty("sub_type").GetString() == "stack"); + + // Verify different lifecycle values + var lifecycles = appliesArray.Select(e => e.GetProperty("lifecycle").GetString()).ToList(); + lifecycles.Should().Contain("ga"); + lifecycles.Should().Contain("beta"); + lifecycles.Should().Contain("deprecated"); + } +} From f21411a12a6887b7f76554921d12ecbeebcca8be Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 12:04:42 +0200 Subject: [PATCH 5/8] Refactor Elasticsearch exporter mappings, optimize `ApplicableTo` handling, and improve index creation logic --- .../AppliesTo/ApplicableToJsonConverter.cs | 36 ++++++++----------- .../Elasticsearch/ElasticsearchExporter.cs | 10 +++--- .../ElasticsearchMarkdownExporter.cs | 33 +++++++++-------- 3 files changed, 38 insertions(+), 41 deletions(-) diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs index 818b178ce..d3779e525 100644 --- a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs @@ -167,9 +167,7 @@ public override void Write(Utf8JsonWriter writer, ApplicableTo value, JsonSerial // Stack if (value.Stack != null) - { WriteApplicabilityEntries(writer, "stack", "stack", value.Stack); - } // Deployment if (value.Deployment != null) @@ -197,9 +195,7 @@ public override void Write(Utf8JsonWriter writer, ApplicableTo value, JsonSerial // Product (simple) if (value.Product != null) - { WriteApplicabilityEntries(writer, "product", "product", value.Product); - } // ProductApplicability (specific products) if (value.ProductApplicability != null) @@ -210,8 +206,7 @@ public override void Write(Utf8JsonWriter writer, ApplicableTo value, JsonSerial var yamlAlias = property.GetCustomAttribute()?.Alias; if (yamlAlias != null) { - var propertyValue = property.GetValue(value.ProductApplicability) as AppliesCollection; - if (propertyValue != null) + if (property.GetValue(value.ProductApplicability) is AppliesCollection propertyValue) WriteApplicabilityEntries(writer, "product", yamlAlias, propertyValue); } } @@ -220,22 +215,19 @@ public override void Write(Utf8JsonWriter writer, ApplicableTo value, JsonSerial writer.WriteEndArray(); } - private static ProductLifecycle ParseLifecycle(string lifecycleStr) + private static ProductLifecycle ParseLifecycle(string lifecycleStr) => lifecycleStr.ToLowerInvariant() switch { - return lifecycleStr.ToLowerInvariant() switch - { - "preview" => ProductLifecycle.TechnicalPreview, - "beta" => ProductLifecycle.Beta, - "ga" => ProductLifecycle.GenerallyAvailable, - "deprecated" => ProductLifecycle.Deprecated, - "removed" => ProductLifecycle.Removed, - "unavailable" => ProductLifecycle.Unavailable, - "development" => ProductLifecycle.Development, - "planned" => ProductLifecycle.Planned, - "discontinued" => ProductLifecycle.Discontinued, - _ => ProductLifecycle.GenerallyAvailable - }; - } + "preview" => ProductLifecycle.TechnicalPreview, + "beta" => ProductLifecycle.Beta, + "ga" => ProductLifecycle.GenerallyAvailable, + "deprecated" => ProductLifecycle.Deprecated, + "removed" => ProductLifecycle.Removed, + "unavailable" => ProductLifecycle.Unavailable, + "development" => ProductLifecycle.Development, + "planned" => ProductLifecycle.Planned, + "discontinued" => ProductLifecycle.Discontinued, + _ => ProductLifecycle.GenerallyAvailable + }; private static void WriteApplicabilityEntries(Utf8JsonWriter writer, string type, string subType, AppliesCollection collection) { @@ -261,7 +253,7 @@ private static void WriteApplicabilityEntries(Utf8JsonWriter writer, string type }; writer.WriteString("lifecycle", lifecycleName); - // Write version + // Write the version if (applicability.Version is not null) writer.WriteString("version", applicability.Version.ToString()); diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs index b3fe4d4e9..4fa74f5e7 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs @@ -193,15 +193,15 @@ protected static string CreateMapping(string? inferenceId) => "prefix": { "type": "text", "analyzer" : "hierarchy_analyzer" } } }, - "applies" : { - "type" : "nested" + "applies_to" : { + "type" : "nested", "properties" : { "type" : { "type" : "keyword" }, "sub-type" : { "type" : "keyword" }, "lifecycle" : { "type" : "keyword" }, - "version" : { "type" : "version" }, - } - }, + "version" : { "type" : "version" } + } + }, "hash" : { "type" : "keyword" }, "title": { "type": "text", diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs index 64c3198e0..571c05415 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Search; @@ -82,17 +83,16 @@ public async ValueTask StartAsync(Cancel ctx = default) { _ = await _lexicalChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); - var semanticIndex = _semanticChannel.Channel.IndexName; var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest"); - var semanticIndexHead = await _transport.HeadAsync(semanticWriteAlias, ctx); - if (!semanticIndexHead.ApiCallDetails.HasSuccessfulStatusCode) + var semanticIndexAvailable = await _transport.HeadAsync(semanticWriteAlias, ctx); + _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); + var semanticIndex = _semanticChannel.Channel.IndexName; + if (!semanticIndexAvailable.ApiCallDetails.HasSuccessfulStatusCode) { - _logger.LogInformation("No semantic index exists yet, creating index {Index} for semantic search", semanticIndex); - _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); + _logger.LogInformation("No semantic index existed yet, creating index {Index} for semantic search", semanticIndex); var semanticIndexPut = await _transport.PutAsync(semanticIndex, PostData.String("{}"), ctx); if (!semanticIndexPut.ApiCallDetails.HasSuccessfulStatusCode) throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}"); - _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx); if (!_endpoint.ForceReindex) { _indexStrategy = IngestStrategy.Multiplex; @@ -152,12 +152,13 @@ public async ValueTask StopAsync(Cancel ctx = default) throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}"); _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx); } + var destinationIndex = _semanticChannel.Channel.IndexName; - _logger.LogInformation("_reindex updates: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, semanticWriteAlias); + _logger.LogInformation("_reindex updates: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, destinationIndex); var request = PostData.String(@" { ""dest"": { - ""index"": """ + semanticWriteAlias + @""" + ""index"": """ + destinationIndex + @""" }, ""source"": { ""index"": """ + lexicalWriteAlias + @""", @@ -171,13 +172,13 @@ public async ValueTask StopAsync(Cancel ctx = default) } } }"); - await DoReindex(request, lexicalWriteAlias, semanticWriteAlias, "updates", ctx); + await DoReindex(request, lexicalWriteAlias, destinationIndex, "updates", ctx); - _logger.LogInformation("_reindex deletions: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, semanticWriteAlias); + _logger.LogInformation("_reindex deletions: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, destinationIndex); request = PostData.String(@" { ""dest"": { - ""index"": """ + semanticWriteAlias + @""" + ""index"": """ + destinationIndex + @""" }, ""script"": { ""source"": ""ctx.op = \""delete\"""" @@ -194,7 +195,7 @@ public async ValueTask StopAsync(Cancel ctx = default) } } }"); - await DoReindex(request, lexicalWriteAlias, semanticWriteAlias, "deletions", ctx); + await DoReindex(request, lexicalWriteAlias, destinationIndex, "deletions", ctx); await DoDeleteByQuery(lexicalWriteAlias, ctx); @@ -336,6 +337,10 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, ? body[..Math.Min(body.Length, 400)] + " " + string.Join(" \n- ", headings) : string.Empty; + // this is temporary until https://github.com/elastic/docs-builder/pull/2070 lands + // this PR will add a service for us to resolve to a versioning scheme. + var appliesTo = fileContext.SourceFile.YamlFrontMatter?.AppliesTo ?? ApplicableTo.Default; + var doc = new DocumentationDocument { Url = url, @@ -344,7 +349,7 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, StrippedBody = body.StripMarkdown(), Description = fileContext.SourceFile.YamlFrontMatter?.Description, Abstract = @abstract, - Applies = fileContext.SourceFile.YamlFrontMatter?.AppliesTo, + Applies = appliesTo, UrlSegmentCount = url.Split('/', StringSplitOptions.RemoveEmptyEntries).Length, Parents = navigation.GetParentsOfMarkdownFile(file).Select(i => new ParentDocument { @@ -357,7 +362,7 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, var semanticHash = _semanticChannel.Channel.ChannelHash; var lexicalHash = _lexicalChannel.Channel.ChannelHash; var hash = HashedBulkUpdate.CreateHash(semanticHash, lexicalHash, - doc.Url, doc.Body ?? string.Empty, string.Join(",", doc.Headings.OrderBy(h => h)) + doc.Url, doc.Body ?? string.Empty, string.Join(",", doc.Headings.OrderBy(h => h)), doc.Url ); doc.Hash = hash; doc.LastUpdated = _batchIndexDate; From af01e018a541b27d8f150efb82b413d1aaf475c5 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 12:56:30 +0200 Subject: [PATCH 6/8] formatting --- ...ApplicableToJsonConverterRoundTripTests.cs | 30 +- ...icableToJsonConverterSerializationTests.cs | 327 +++++++++++------- .../ProductApplicabilityToStringTests.cs | 8 +- ...DocumentationDocumentSerializationTests.cs | 31 +- 4 files changed, 241 insertions(+), 155 deletions(-) diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs index 6ada33838..3b22299f8 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs @@ -14,7 +14,7 @@ public class ApplicableToJsonConverterRoundTripTests private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; [Fact] - public void RoundTrip_Stack_Simple() + public void RoundTripStackSimple() { var original = new ApplicableTo { @@ -30,7 +30,7 @@ public void RoundTrip_Stack_Simple() } [Fact] - public void RoundTrip_Stack_WithVersion() + public void RoundTripStackWithVersion() { var original = new ApplicableTo { @@ -50,7 +50,7 @@ public void RoundTrip_Stack_WithVersion() } [Fact] - public void RoundTrip_Deployment_AllProperties() + public void RoundTripDeploymentAllProperties() { var original = new ApplicableTo { @@ -75,7 +75,7 @@ public void RoundTrip_Deployment_AllProperties() } [Fact] - public void RoundTrip_Serverless_AllProperties() + public void RoundTripServerlessAllProperties() { var original = new ApplicableTo { @@ -98,7 +98,7 @@ public void RoundTrip_Serverless_AllProperties() } [Fact] - public void RoundTrip_Product_Simple() + public void RoundTripProductSimple() { var original = new ApplicableTo { @@ -114,7 +114,7 @@ public void RoundTrip_Product_Simple() } [Fact] - public void RoundTrip_ProductApplicability_SingleProduct() + public void RoundTripProductApplicabilitySingleProduct() { var original = new ApplicableTo { @@ -133,7 +133,7 @@ public void RoundTrip_ProductApplicability_SingleProduct() } [Fact] - public void RoundTrip_ProductApplicability_MultipleProducts() + public void RoundTripProductApplicabilityMultipleProducts() { var original = new ApplicableTo { @@ -158,7 +158,7 @@ public void RoundTrip_ProductApplicability_MultipleProducts() } [Fact] - public void RoundTrip_AllProductApplicability_Properties() + public void RoundTripAllProductApplicabilityProperties() { var original = new ApplicableTo { @@ -219,7 +219,7 @@ public void RoundTrip_AllProductApplicability_Properties() } [Fact] - public void RoundTrip_Complex_AllFieldsPopulated() + public void RoundTripComplexAllFieldsPopulated() { var original = new ApplicableTo { @@ -270,7 +270,7 @@ public void RoundTrip_Complex_AllFieldsPopulated() } [Fact] - public void RoundTrip_AllLifecycles() + public void RoundTripAllLifecycles() { var lifecycles = Enum.GetValues(); var applicabilities = lifecycles.Select(lc => @@ -291,7 +291,7 @@ public void RoundTrip_AllLifecycles() } [Fact] - public void RoundTrip_MultipleApplicabilitiesInCollection() + public void RoundTripMultipleApplicabilitiesInCollection() { var original = new ApplicableTo { @@ -314,7 +314,7 @@ public void RoundTrip_MultipleApplicabilitiesInCollection() } [Fact] - public void RoundTrip_EmptyApplicableTo() + public void RoundTripEmptyApplicableTo() { var original = new ApplicableTo(); @@ -330,7 +330,7 @@ public void RoundTrip_EmptyApplicableTo() } [Fact] - public void RoundTrip_Null_ReturnsNull() + public void RoundTripNullReturnsNull() { ApplicableTo? original = null; @@ -341,7 +341,7 @@ public void RoundTrip_Null_ReturnsNull() } [Fact] - public void RoundTrip_AllVersions_SerializesAsSemanticVersion() + public void RoundTripAllVersionsSerializesAsSemanticVersion() { var original = new ApplicableTo { @@ -358,7 +358,7 @@ public void RoundTrip_AllVersions_SerializesAsSemanticVersion() } [Fact] - public void RoundTrip_ProductAndProductApplicability_BothPresent() + public void RoundTripProductAndProductApplicabilityBothPresent() { var original = new ApplicableTo { diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs index 687363a14..fbff52703 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs @@ -11,10 +11,13 @@ namespace Elastic.Markdown.Tests.AppliesTo; public class ApplicableToJsonConverterSerializationTests { - private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + private readonly JsonSerializerOptions _options = new() + { + WriteIndented = true + }; [Fact] - public void Serialize_Stack_ProducesCorrectJson() + public void SerializeStackProducesCorrectJson() { var applicableTo = new ApplicableTo { @@ -23,202 +26,274 @@ public void Serialize_Stack_ProducesCorrectJson() var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be(""" -[ - { - "type": "stack", - "sub_type": "stack", - "lifecycle": "ga", - "version": "9999.9999.9999" - } -] -"""); + // language=json + json.Should().Be( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); } [Fact] - public void Serialize_StackWithVersion_ProducesCorrectJson() + public void SerializeStackWithVersionProducesCorrectJson() { var applicableTo = new ApplicableTo { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"8.0.0" }]) + Stack = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"8.0.0" + } + ]) }; var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be(""" -[ - { - "type": "stack", - "sub_type": "stack", - "lifecycle": "beta", - "version": "8.0.0" - } -] -"""); + // language=json + json.Should().Be( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "beta", + "version": "8.0.0" + } + ] + """); } [Fact] - public void Serialize_MultipleApplicabilities_ProducesCorrectJson() + public void SerializeMultipleApplicabilitiesProducesCorrectJson() { var applicableTo = new ApplicableTo { Stack = new AppliesCollection( [ - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" } + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"8.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"7.17.0" + } ]) }; var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be(""" -[ - { - "type": "stack", - "sub_type": "stack", - "lifecycle": "ga", - "version": "8.0.0" - }, - { - "type": "stack", - "sub_type": "stack", - "lifecycle": "beta", - "version": "7.17.0" - } -] -"""); + // language=json + json.Should().Be( + """ + [ + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "ga", + "version": "8.0.0" + }, + { + "type": "stack", + "sub_type": "stack", + "lifecycle": "beta", + "version": "7.17.0" + } + ] + """); } [Fact] - public void Serialize_Deployment_ProducesCorrectJson() + public void SerializeDeploymentProducesCorrectJson() { var applicableTo = new ApplicableTo { Deployment = new DeploymentApplicability { - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]), + Ece = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"3.0.0" + } + ]), Ess = AppliesCollection.GenerallyAvailable } }; var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be(""" -[ - { - "type": "deployment", - "sub_type": "ece", - "lifecycle": "ga", - "version": "3.0.0" - }, - { - "type": "deployment", - "sub_type": "ess", - "lifecycle": "ga", - "version": "9999.9999.9999" - } -] -"""); + // language=json + json.Should().Be( + """ + [ + { + "type": "deployment", + "sub_type": "ece", + "lifecycle": "ga", + "version": "3.0.0" + }, + { + "type": "deployment", + "sub_type": "ess", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); } [Fact] - public void Serialize_Serverless_ProducesCorrectJson() + public void SerializeServerlessProducesCorrectJson() { var applicableTo = new ApplicableTo { Serverless = new ServerlessProjectApplicability { - Elasticsearch = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]), + Elasticsearch = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"1.0.0" + } + ]), Security = AppliesCollection.GenerallyAvailable } }; var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be(""" -[ - { - "type": "serverless", - "sub_type": "elasticsearch", - "lifecycle": "beta", - "version": "1.0.0" - }, - { - "type": "serverless", - "sub_type": "security", - "lifecycle": "ga", - "version": "9999.9999.9999" - } -] -"""); + // language=json + json.Should().Be( + """ + [ + { + "type": "serverless", + "sub_type": "elasticsearch", + "lifecycle": "beta", + "version": "1.0.0" + }, + { + "type": "serverless", + "sub_type": "security", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); } [Fact] - public void Serialize_Product_ProducesCorrectJson() + public void SerializeProductProducesCorrectJson() { var applicableTo = new ApplicableTo { - Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.5.0" }]) + Product = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.TechnicalPreview, + Version = (SemVersion)"0.5.0" + } + ]) }; var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be(""" -[ - { - "type": "product", - "sub_type": "product", - "lifecycle": "preview", - "version": "0.5.0" - } -] -"""); + // language=json + json.Should().Be( + """ + [ + { + "type": "product", + "sub_type": "product", + "lifecycle": "preview", + "version": "0.5.0" + } + ] + """); } [Fact] - public void Serialize_ProductApplicability_ProducesCorrectJson() + public void SerializeProductApplicabilityProducesCorrectJson() { var applicableTo = new ApplicableTo { ProductApplicability = new ProductApplicability { - Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]), + Ecctl = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Deprecated, + Version = (SemVersion)"5.0.0" + } + ]), ApmAgentDotnet = AppliesCollection.GenerallyAvailable } }; var json = JsonSerializer.Serialize(applicableTo, _options); - json.Should().Be(""" -[ - { - "type": "product", - "sub_type": "ecctl", - "lifecycle": "deprecated", - "version": "5.0.0" - }, - { - "type": "product", - "sub_type": "apm-agent-dotnet", - "lifecycle": "ga", - "version": "9999.9999.9999" - } -] -"""); + // language=json + json.Should().Be( + """ + [ + { + "type": "product", + "sub_type": "ecctl", + "lifecycle": "deprecated", + "version": "5.0.0" + }, + { + "type": "product", + "sub_type": "apm-agent-dotnet", + "lifecycle": "ga", + "version": "9999.9999.9999" + } + ] + """); } [Fact] - public void Serialize_AllLifecycles_ProducesCorrectJson() + public void SerializeAllLifecyclesProducesCorrectJson() { var applicableTo = new ApplicableTo { Stack = new AppliesCollection( [ - new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"1.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"1.0.0" }, - new Applicability { Lifecycle = ProductLifecycle.Removed, Version = (SemVersion)"1.0.0" } + new Applicability + { + Lifecycle = ProductLifecycle.TechnicalPreview, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Deprecated, + Version = (SemVersion)"1.0.0" + }, + new Applicability + { + Lifecycle = ProductLifecycle.Removed, + Version = (SemVersion)"1.0.0" + } ]) }; @@ -232,11 +307,17 @@ public void Serialize_AllLifecycles_ProducesCorrectJson() } [Fact] - public void Serialize_Complex_ProducesCorrectJson() + public void SerializeComplexProducesCorrectJson() { var applicableTo = new ApplicableTo { - Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]), + Stack = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.GenerallyAvailable, + Version = (SemVersion)"8.0.0" + } + ]), Deployment = new DeploymentApplicability { Ece = AppliesCollection.GenerallyAvailable @@ -262,7 +343,7 @@ public void Serialize_Complex_ProducesCorrectJson() } [Fact] - public void Serialize_Empty_ProducesEmptyArray() + public void SerializeEmptyProducesEmptyArray() { var applicableTo = new ApplicableTo(); @@ -272,14 +353,20 @@ public void Serialize_Empty_ProducesEmptyArray() } [Fact] - public void Serialize_ValidatesJsonStructure() + public void SerializeValidatesJsonStructure() { var original = new ApplicableTo { Stack = AppliesCollection.GenerallyAvailable, Deployment = new DeploymentApplicability { - Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.0.0" }]) + Ece = new AppliesCollection([ + new Applicability + { + Lifecycle = ProductLifecycle.Beta, + Version = (SemVersion)"3.0.0" + } + ]) } }; diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs index 2342522e9..61dc00e80 100644 --- a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs +++ b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs @@ -13,7 +13,7 @@ namespace Elastic.Markdown.Tests.AppliesTo; public class ProductApplicabilityToStringTests { [Fact] - public void ProductApplicability_ToString_IncludesAllProperties() + public void ProductApplicabilityToStringIncludesAllProperties() { // Create a ProductApplicability with all properties set var productApplicability = new ProductApplicability(); @@ -45,7 +45,7 @@ public void ProductApplicability_ToString_IncludesAllProperties() } [Fact] - public void ProductApplicability_ToString_WithSomeProperties_OnlyIncludesSetProperties() + public void ProductApplicabilityToStringWithSomePropertiesOnlyIncludesSetProperties() { var productApplicability = new ProductApplicability { @@ -65,7 +65,7 @@ public void ProductApplicability_ToString_WithSomeProperties_OnlyIncludesSetProp } [Fact] - public void ProductApplicability_ToString_Empty_ReturnsEmptyString() + public void ProductApplicabilityToStringEmptyReturnsEmptyString() { var productApplicability = new ProductApplicability(); @@ -75,7 +75,7 @@ public void ProductApplicability_ToString_Empty_ReturnsEmptyString() } [Fact] - public void ProductApplicability_ToString_PropertyOrder_MatchesReflectionOrder() + public void ProductApplicabilityToStringPropertyOrderMatchesReflectionOrder() { // This test ensures that properties appear in the order they are defined var productApplicability = new ProductApplicability diff --git a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs index d77b7181d..b3258a994 100644 --- a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs +++ b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Globalization; using System.Text.Json; using Elastic.Documentation; using Elastic.Documentation.AppliesTo; @@ -16,7 +17,7 @@ public class DocumentationDocumentSerializationTests private readonly JsonSerializerOptions _options = new(SourceGenerationContext.Default.Options); [Fact] - public void Serialize_DocumentWithStackAppliesTo_ProducesCorrectJson() + public void SerializeDocumentWithStackAppliesToProducesCorrectJson() { var doc = new DocumentationDocument { @@ -32,7 +33,7 @@ public void Serialize_DocumentWithStackAppliesTo_ProducesCorrectJson() var jsonDoc = JsonDocument.Parse(json); var root = jsonDoc.RootElement; - // Verify applies_to exists + // Verify applies_to existing root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue(); appliesTo.ValueKind.Should().Be(JsonValueKind.Array); @@ -48,7 +49,7 @@ public void Serialize_DocumentWithStackAppliesTo_ProducesCorrectJson() } [Fact] - public void Serialize_DocumentWithDeploymentAppliesTo_ProducesCorrectJson() + public void SerializeDocumentWithDeploymentAppliesToProducesCorrectJson() { var doc = new DocumentationDocument { @@ -88,7 +89,7 @@ public void Serialize_DocumentWithDeploymentAppliesTo_ProducesCorrectJson() } [Fact] - public void Serialize_DocumentWithServerlessAppliesTo_ProducesCorrectJson() + public void SerializeDocumentWithServerlessAppliesToProducesCorrectJson() { var doc = new DocumentationDocument { @@ -128,7 +129,7 @@ public void Serialize_DocumentWithServerlessAppliesTo_ProducesCorrectJson() } [Fact] - public void Serialize_DocumentWithProductAppliesTo_ProducesCorrectJson() + public void SerializeDocumentWithProductAppliesToProducesCorrectJson() { var doc = new DocumentationDocument { @@ -156,7 +157,7 @@ public void Serialize_DocumentWithProductAppliesTo_ProducesCorrectJson() } [Fact] - public void Serialize_DocumentWithProductApplicability_ProducesCorrectJson() + public void SerializeDocumentWithProductApplicabilityProducesCorrectJson() { var doc = new DocumentationDocument { @@ -196,7 +197,7 @@ public void Serialize_DocumentWithProductApplicability_ProducesCorrectJson() } [Fact] - public void Serialize_DocumentWithComplexAppliesTo_ProducesCorrectJson() + public void SerializeDocumentWithComplexAppliesToProducesCorrectJson() { var doc = new DocumentationDocument { @@ -231,7 +232,7 @@ public void Serialize_DocumentWithComplexAppliesTo_ProducesCorrectJson() } [Fact] - public void Serialize_DocumentWithNullAppliesTo_OmitsField() + public void SerializeDocumentWithNullAppliesToOmitsField() { var doc = new DocumentationDocument { @@ -247,9 +248,7 @@ public void Serialize_DocumentWithNullAppliesTo_OmitsField() // With default JSON options, null values might be omitted or serialized as null // Let's check both possibilities if (root.TryGetProperty("applies_to", out var appliesTo)) - { appliesTo.ValueKind.Should().Be(JsonValueKind.Null); - } else { // Field is omitted, which is also acceptable @@ -258,7 +257,7 @@ public void Serialize_DocumentWithNullAppliesTo_OmitsField() } [Fact] - public void Serialize_DocumentWithEmptyAppliesTo_ProducesEmptyArray() + public void SerializeDocumentWithEmptyAppliesToProducesEmptyArray() { var doc = new DocumentationDocument { @@ -277,15 +276,15 @@ public void Serialize_DocumentWithEmptyAppliesTo_ProducesEmptyArray() } [Fact] - public void RoundTrip_DocumentWithAppliesTo_PreservesData() + public void RoundTripDocumentWithAppliesToPreservesData() { var original = new DocumentationDocument { Url = "/test/roundtrip", Title = "Round Trip Test", Hash = "abc123", - BatchIndexDate = DateTimeOffset.Parse("2024-01-15T10:00:00Z"), - LastUpdated = DateTimeOffset.Parse("2024-01-15T09:00:00Z"), + BatchIndexDate = DateTimeOffset.Parse("2024-01-15T10:00:00Z", CultureInfo.InvariantCulture), + LastUpdated = DateTimeOffset.Parse("2024-01-15T09:00:00Z", CultureInfo.InvariantCulture), Applies = new ApplicableTo { Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.5.0" }]), @@ -305,7 +304,7 @@ public void RoundTrip_DocumentWithAppliesTo_PreservesData() var deserialized = JsonSerializer.Deserialize(json, _options); deserialized.Should().NotBeNull(); - deserialized!.Url.Should().Be(original.Url); + deserialized.Url.Should().Be(original.Url); deserialized.Title.Should().Be(original.Title); deserialized.Applies.Should().NotBeNull(); deserialized.Applies!.Stack.Should().BeEquivalentTo(original.Applies!.Stack); @@ -314,7 +313,7 @@ public void RoundTrip_DocumentWithAppliesTo_PreservesData() } [Fact] - public void Serialize_DocumentWithMultipleApplicabilitiesPerType_ProducesMultipleArrayEntries() + public void SerializeDocumentWithMultipleApplicabilitiesPerTypeProducesMultipleArrayEntries() { var doc = new DocumentationDocument { From 0e68c58056d20f45d1e43741ea382712a8d68a14 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 19:30:59 +0200 Subject: [PATCH 7/8] Ensure we reuse and create new indices based on channel hashes (settings/mappings) --- Directory.Packages.props | 2 +- .../Search/DocumentationDocument.cs | 1 - .../Elasticsearch/ElasticsearchExporter.cs | 37 +++++++--- .../ElasticsearchMarkdownExporter.cs | 72 ++++++++++++++----- 4 files changed, 83 insertions(+), 29 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index deb03b526..40a57e8b4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + diff --git a/src/Elastic.Documentation/Search/DocumentationDocument.cs b/src/Elastic.Documentation/Search/DocumentationDocument.cs index 8a0eb6781..8470fc3b5 100644 --- a/src/Elastic.Documentation/Search/DocumentationDocument.cs +++ b/src/Elastic.Documentation/Search/DocumentationDocument.cs @@ -4,7 +4,6 @@ using System.Text.Json.Serialization; using Elastic.Documentation.AppliesTo; -using Elastic.Documentation.Extensions; namespace Elastic.Documentation.Search; diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs index 4fa74f5e7..e994ff95a 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs @@ -93,8 +93,8 @@ Func createOptions options.ExportBufferCallback = () => { var count = Interlocked.Increment(ref i); - _logger.LogInformation("Exported {Count} documents to Elasticsearch index {Format}", - count * endpoint.BufferSize, string.Format(options.IndexFormat, "latest")); + _logger.LogInformation("Exported {Count} documents to Elasticsearch index {IndexName}", + count * endpoint.BufferSize, Channel?.IndexName ?? string.Format(options.IndexFormat, "latest")); }; options.ExportExceptionCallback = e => { @@ -103,7 +103,7 @@ Func createOptions }; options.ServerRejectionCallback = items => _logger.LogInformation("Server rejection: {Rejection}", items.First().Item2); Channel = createChannel(options); - _logger.LogInformation($"Bootstrapping {nameof(SemanticIndexChannel)} Elasticsearch target for indexing"); + _logger.LogInformation("Created {Channel} Elasticsearch target for indexing", typeof(TChannel).Name); } public async ValueTask StopAsync(Cancel ctx = default) @@ -183,7 +183,7 @@ protected static string CreateMappingSetting() => """; protected static string CreateMapping(string? inferenceId) => - $$""" + $$$$"""" { "properties": { "url" : { @@ -200,8 +200,27 @@ protected static string CreateMapping(string? inferenceId) => "sub-type" : { "type" : "keyword" }, "lifecycle" : { "type" : "keyword" }, "version" : { "type" : "version" } - } - }, + } + }, + "parents" : { + "type" : "object", + "properties" : { + "url" : { + "type": "keyword", + "fields": { + "match": { "type": "text" }, + "prefix": { "type": "text", "analyzer" : "hierarchy_analyzer" } + } + }, + "title": { + "type": "text", + "search_analyzer": "synonyms_analyzer", + "fields": { + "keyword": { "type": "keyword" } + } + } + } + }, "hash" : { "type" : "keyword" }, "title": { "type": "text", @@ -210,7 +229,7 @@ protected static string CreateMapping(string? inferenceId) => "keyword": { "type": "keyword" } - {{(!string.IsNullOrWhiteSpace(inferenceId) ? $$""", "semantic_text": {{{InferenceMapping(inferenceId)}}}""" : "")}} + {{{{(!string.IsNullOrWhiteSpace(inferenceId) ? $$""", "semantic_text": {{{InferenceMapping(inferenceId)}}}""" : "")}}}} } }, "url_segment_count": { @@ -224,10 +243,10 @@ protected static string CreateMapping(string? inferenceId) => "search_analyzer": "highlight_analyzer", "term_vector": "with_positions_offsets" } - {{(!string.IsNullOrWhiteSpace(inferenceId) ? AbstractInferenceMapping(inferenceId) : AbstractMapping())}} + {{{{(!string.IsNullOrWhiteSpace(inferenceId) ? AbstractInferenceMapping(inferenceId) : AbstractMapping())}}}} } } - """; + """"; private static string AbstractMapping() => """ diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs index 571c05415..bba070ce4 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs @@ -34,6 +34,8 @@ public class ElasticsearchMarkdownExporter : IMarkdownExporter, IDisposable private readonly DateTimeOffset _batchIndexDate = DateTimeOffset.UtcNow; private readonly DistributedTransport _transport; private IngestStrategy _indexStrategy; + private string _currentLexicalHash = string.Empty; + private string _currentSemanticHash = string.Empty; public ElasticsearchMarkdownExporter( ILoggerFactory logFactory, @@ -81,25 +83,53 @@ string indexNamespace /// public async ValueTask StartAsync(Cancel ctx = default) { + _currentLexicalHash = await _lexicalChannel.Channel.GetIndexTemplateHashAsync(ctx) ?? string.Empty; + _currentSemanticHash = await _semanticChannel.Channel.GetIndexTemplateHashAsync(ctx) ?? string.Empty; + _ = await _lexicalChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); - var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest"); - var semanticIndexAvailable = await _transport.HeadAsync(semanticWriteAlias, ctx); - _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); - var semanticIndex = _semanticChannel.Channel.IndexName; - if (!semanticIndexAvailable.ApiCallDetails.HasSuccessfulStatusCode) + // if the previous hash does not match the current hash, we know already we want to multiplex to a new index + if (_currentLexicalHash != _lexicalChannel.Channel.ChannelHash) + _indexStrategy = IngestStrategy.Multiplex; + + if (!_endpoint.NoSemantic) { - _logger.LogInformation("No semantic index existed yet, creating index {Index} for semantic search", semanticIndex); - var semanticIndexPut = await _transport.PutAsync(semanticIndex, PostData.String("{}"), ctx); - if (!semanticIndexPut.ApiCallDetails.HasSuccessfulStatusCode) - throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}"); - if (!_endpoint.ForceReindex) + var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest"); + var semanticIndexAvailable = await _transport.HeadAsync(semanticWriteAlias, ctx); + if (!semanticIndexAvailable.ApiCallDetails.HasSuccessfulStatusCode && _endpoint is { ForceReindex: false, NoSemantic: false }) { _indexStrategy = IngestStrategy.Multiplex; - _logger.LogInformation("Index strategy set to multiplex because {SemanticIndex} does not exist, pass --force-reindex to always use reindex", semanticIndex); + _logger.LogInformation("Index strategy set to multiplex because {SemanticIndex} does not exist, pass --force-reindex to always use reindex", semanticWriteAlias); } + + //try re-use index if we are re-indexing. Multiplex should always go to a new index + _semanticChannel.Channel.Options.TryReuseIndex = _indexStrategy == IngestStrategy.Reindex; + _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx); + } + + var lexicalIndexExists = await IndexExists(_lexicalChannel.Channel.IndexName) ? "existing" : "new"; + var semanticIndexExists = await IndexExists(_semanticChannel.Channel.IndexName) ? "existing" : "new"; + if (_currentLexicalHash != _lexicalChannel.Channel.ChannelHash) + { + _indexStrategy = IngestStrategy.Multiplex; + _logger.LogInformation("Multiplexing lexical new index: '{Index}' since current hash on server '{HashCurrent}' does not match new '{HashNew}'", + _lexicalChannel.Channel.IndexName, _currentLexicalHash, _lexicalChannel.Channel.ChannelHash); + } + else + _logger.LogInformation("Targeting {State} lexical: '{Index}'", lexicalIndexExists, _lexicalChannel.Channel.IndexName); + + if (!_endpoint.NoSemantic && _currentSemanticHash != _semanticChannel.Channel.ChannelHash) + { + _indexStrategy = IngestStrategy.Multiplex; + _logger.LogInformation("Multiplexing new index '{Index}' since current hash on server '{HashCurrent}' does not match new '{HashNew}'", + _semanticChannel.Channel.IndexName, _currentSemanticHash, _semanticChannel.Channel.ChannelHash); } + else if (!_endpoint.NoSemantic) + _logger.LogInformation("Targeting {State} semantical: '{Index}'", semanticIndexExists, _semanticChannel.Channel.IndexName); + _logger.LogInformation("Using {IndexStrategy} to sync lexical index to semantic index", _indexStrategy.ToStringFast(true)); + + async ValueTask IndexExists(string name) => (await _transport.HeadAsync(name, ctx)).ApiCallDetails.HasSuccessfulStatusCode; } private async ValueTask CountAsync(string index, string body, Cancel ctx = default) @@ -113,7 +143,6 @@ public async ValueTask StopAsync(Cancel ctx = default) { var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest"); var lexicalWriteAlias = string.Format(_lexicalChannel.Channel.Options.IndexFormat, "latest"); - var semanticIndex = _semanticChannel.Channel.IndexName; var stopped = await _lexicalChannel.StopAsync(ctx); if (!stopped) @@ -125,23 +154,28 @@ public async ValueTask StopAsync(Cancel ctx = default) { if (!_endpoint.NoSemantic) _ = await _semanticChannel.StopAsync(ctx); - else - _logger.LogInformation("--no-semantic was specified when doing multiplex writes, not rolling over {SemanticIndex}", semanticIndex); // cleanup lexical index of old data await DoDeleteByQuery(lexicalWriteAlias, ctx); + // need to refresh the lexical index to ensure that the delete by query is available _ = await _lexicalChannel.RefreshAsync(ctx); - _logger.LogInformation("Finish sync to semantic index using {IndexStrategy} strategy", _indexStrategy.ToStringFast(true)); await QueryDocumentCounts(ctx); + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + if (_endpoint.NoSemantic) + _logger.LogInformation("Finish indexing {IndexStrategy} strategy", _indexStrategy.ToStringFast(true)); + else + _logger.LogInformation("Finish syncing to semantic in {IndexStrategy} strategy", _indexStrategy.ToStringFast(true)); return; } if (_endpoint.NoSemantic) { - _logger.LogInformation("--no-semantic was specified so exiting early before reindexing to {Index}", semanticIndex); + _logger.LogInformation("--no-semantic was specified so exiting early before reindexing to {Index}", lexicalWriteAlias); return; } + var semanticIndex = _semanticChannel.Channel.IndexName; + // check if the alias exists var semanticIndexHead = await _transport.HeadAsync(semanticWriteAlias, ctx); if (!semanticIndexHead.ApiCallDetails.HasSuccessfulStatusCode) { @@ -150,7 +184,6 @@ public async ValueTask StopAsync(Cancel ctx = default) var semanticIndexPut = await _transport.PutAsync(semanticIndex, PostData.String("{}"), ctx); if (!semanticIndexPut.ApiCallDetails.HasSuccessfulStatusCode) throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}"); - _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx); } var destinationIndex = _semanticChannel.Channel.IndexName; @@ -199,6 +232,9 @@ public async ValueTask StopAsync(Cancel ctx = default) await DoDeleteByQuery(lexicalWriteAlias, ctx); + _ = await _lexicalChannel.Channel.ApplyLatestAliasAsync(ctx); + _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx); + _ = await _lexicalChannel.RefreshAsync(ctx); _ = await _semanticChannel.RefreshAsync(ctx); @@ -276,7 +312,7 @@ private async ValueTask DoDeleteByQuery(string lexicalWriteAlias, Cancel ctx) private async ValueTask DoReindex(PostData request, string lexicalWriteAlias, string semanticWriteAlias, string typeOfSync, Cancel ctx) { - var reindexUrl = "/_reindex?wait_for_completion=false&require_alias=true&scroll=10m"; + var reindexUrl = "/_reindex?wait_for_completion=false&scroll=10m"; var reindexNewChanges = await _transport.PostAsync(reindexUrl, request, ctx); var taskId = reindexNewChanges.Body.Get("task"); if (string.IsNullOrWhiteSpace(taskId)) From dcb26a12a430b6266c3a37d4baa79ca14a1c9066 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 21 Oct 2025 19:37:11 +0200 Subject: [PATCH 8/8] simplify raw string --- .../Exporters/Elasticsearch/ElasticsearchExporter.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs index e994ff95a..3068e8f59 100644 --- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs +++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs @@ -183,7 +183,7 @@ protected static string CreateMappingSetting() => """; protected static string CreateMapping(string? inferenceId) => - $$$$"""" + $$""" { "properties": { "url" : { @@ -229,7 +229,7 @@ protected static string CreateMapping(string? inferenceId) => "keyword": { "type": "keyword" } - {{{{(!string.IsNullOrWhiteSpace(inferenceId) ? $$""", "semantic_text": {{{InferenceMapping(inferenceId)}}}""" : "")}}}} + {{(!string.IsNullOrWhiteSpace(inferenceId) ? $$""", "semantic_text": {{{InferenceMapping(inferenceId)}}}""" : "")}} } }, "url_segment_count": { @@ -243,10 +243,10 @@ protected static string CreateMapping(string? inferenceId) => "search_analyzer": "highlight_analyzer", "term_vector": "with_positions_offsets" } - {{{{(!string.IsNullOrWhiteSpace(inferenceId) ? AbstractInferenceMapping(inferenceId) : AbstractMapping())}}}} + {{(!string.IsNullOrWhiteSpace(inferenceId) ? AbstractInferenceMapping(inferenceId) : AbstractMapping())}} } } - """"; + """; private static string AbstractMapping() => """