/* SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. */ /* * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. * * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ using System; using System.Collections.Generic; using System.Reflection; using System.Text; using OpenSearch.OpenSearch.Xunit.XunitPlumbing; using OpenSearch.Net; using FluentAssertions; using OpenSearch.Client; using System.Runtime.Serialization; using Newtonsoft.Json.Linq; using Tests.Core.Client; using Tests.Framework; using Tests.Framework.DocumentationTests; using static Tests.Core.Serialization.SerializationTestHelper; using static OpenSearch.Client.Infer; namespace Tests.ClientConcepts.HighLevel.Mapping { /**[[parent-child-relationships]] * === Parent/Child relationships * * Multiple types are no longer suppported in a single index. One reason for this is that if for instance * two types have the same `name` property, this property needed to be mapped exactly the same for both types, but all the APIs act as if you can map * them individually, often causing confusion. Essentially, `_type` always acted as a discriminating field within an index but was often explained * as being more special than this. * * So how do you create a parent join, now that indices no longer allow you store different types in the same index and therefor also * not on the same shard? * */ public class ParentChildRelationships : DocumentationTestBase { /** * ==== Parent And Child example * * In the following contrived example we create two .NET types called `MyParent` and `MyChild` both extending from `MyDocument`. * There is no requirement that the parent and child .NET types are related at all. The types could be plain POCO's or `MyChild` * could be a subclass of `MyParent`. The only requirement is that they have **a** property where the property type is `JoinField`. * * As per OpenSearch's constraints you should only have **a single** property of type JoinField on your POCO. * */ public abstract class MyDocument { public int Id { get; set; } public JoinField MyJoinField { get; set; } } public class MyParent : MyDocument { [Text] public string ParentProperty { get; set; } } public class MyChild : MyDocument { [Text] public string ChildProperty { get; set; } } /** * ==== Parent And Child mapping * * In the following example we setup our client and give our types prefered index and type names. In OSC we can * also give a type a preferred `RelationName` as can be seen on the `DefaultMappingFor`. * * Also note that we give `MyChild` and `MyParent` the same default `doc` type name to make sure they end up in the same index * under the same type. * */ [U] public void SimpleParentChildMapping() { var connectionPool = new SingleNodeConnectionPool(new Uri("http://localhost:9200")); var connectionSettings = new ConnectionSettings(connectionPool, new InMemoryConnection()) // <1> for the purposes of this example, an in memory connection is used which doesn't actually send a request. In your application, you'd use the default connection or your own implementation that actually sends a request. .DefaultMappingFor(m => m.IndexName("index")) .DefaultMappingFor(m => m.IndexName("index")) .DefaultMappingFor(m => m.IndexName("index").RelationName("parent")); var client = new OpenSearchClient(connectionSettings); // hide connectionSettings.DisableDirectStreaming(); /** * With the `ConnectionSettings` set up, we can proceed to map `MyParent` and `MyChild` as part of the create index request. */ var createIndexResponse = client.Indices.Create("index", c => c .Index() .Map(m => m .RoutingField(r => r.Required()) // <1> recommended to make the routing field mandatory so you can not accidentally forget .AutoMap() // <2> Map all of the `MyParent` properties .AutoMap() // <3> Map all of the `MyChild` properties .Properties(props => props .Join(j => j // <4> Additionally map the `JoinField` since it is not automatically mapped by `AutoMap()` .Name(p => p.MyJoinField) .Relations(r => r .Join() ) ) ) ) ); /** * We call `AutoMap()` for both types to discover properties of both .NET types. `AutoMap()` won't automatically setup the * join field mapping though because OSC can not infer all the `Relations` that are required by your domain. * * In this case we setup `MyChild` to be child of `MyParent`. `.Join()` has many overloads so be sure to check them out if you * need to map not one but multiple children. * */ //json var expected = new { mappings = new { _routing = new { required = true }, properties = new { parentProperty = new {type = "text"}, childProperty = new {type = "text"}, id = new {type = "integer"}, myJoinField = new { type = "join", relations = new { parent = "mychild" } } } } }; /** * Note how `MyParent`'s relation name is `parent` because of the mapping on connection settings. This also comes in handy * later when doing strongly typed `has_child` and `has_parent` queries. */ //hide WithConnectionSettings(s => connectionSettings) .Expect(expected).FromRequest(createIndexResponse); } /** * ==== Indexing parents or children * * Now that we have our join field mapping set up on the index, we can proceed to index parent and child documents. */ [U] public void Indexing() { // hide var client = TestClient.DisabledStreaming; /** * To mark a document with the relation name of the parent `MyParent` all of the following three ways are equivalent. * * In the first we explicitly call `JoinField.Root` to mark this document as the root of a parent child relationship namely * that of `MyParent`. In the following examples we rely on implicit conversion from `string` and `Type` to do the same. */ var parentDocument = new MyParent { Id = 1, ParentProperty = "a parent prop", MyJoinField = JoinField.Root() }; parentDocument = new MyParent { Id = 1, ParentProperty = "a parent prop", MyJoinField = typeof(MyParent) // <1> this lets the join data type know this is a root document of type `myparent` }; parentDocument = new MyParent { Id = 1, ParentProperty = "a parent prop", MyJoinField = "myparent" // <2> this lets the join data type know this is a root document of type `myparent` }; var indexParent = client.IndexDocument(parentDocument); //json var expected = new { id = 1, parentProperty = "a parent prop", myJoinField = "myparent" }; // hide Expect(expected).FromRequest(indexParent); /** * Linking the child document to its parent follows a similar pattern. * Here we create a link by inferring the id from our parent instance `parentDocument` */ var indexChild = client.IndexDocument(new MyChild { MyJoinField = JoinField.Link(parentDocument) }); /** * or here we are simply stating this document is of type `mychild` and should be linked * to parent id 1 from `parentDocument`. */ indexChild = client.IndexDocument(new MyChild { Id = 2, MyJoinField = JoinField.Link(1) }); //json var childJson = new { id = 2, myJoinField = new { name = "mychild", parent = "1" } }; // hide Expect(childJson).FromRequest(indexChild); /** * The mapping already links `myparent` as the parent type so we only need supply the parent id. * In fact there are many ways to create join field: */ Expect("myparent").WhenSerializing(JoinField.Root(typeof(MyParent))); Expect("myparent").WhenSerializing(JoinField.Root(Relation())); Expect("myparent").WhenSerializing(JoinField.Root()); Expect("myparent").WhenSerializing(JoinField.Root("myparent")); var childLink = new { name = "mychild", parent = "1" }; Expect(childLink).WhenSerializing(JoinField.Link(1)); Expect(childLink).WhenSerializing(JoinField.Link(parentDocument)); Expect(childLink).WhenSerializing(JoinField.Link("mychild", 1)); Expect(childLink).WhenSerializing(JoinField.Link(typeof(MyChild), 1)); } /** * ==== Routing parent child documents * * A parent and all of it's (grand)children still need to live on the same shard so you still need to take care of specifying routing. * * In the past you would have to provide the parent id on the request using `parent=` this was always an alias for routing * and thus in OpenSearch you need to provide `routing=` instead. * * OSC has a handy helper to infer the correct routing value given a document that is smart enough to find the join field and infer * correct parent. */ [U] public void Inference() { // hide var client = TestClient.DisabledStreaming; var infer = client.Infer; var parent = new MyParent {Id = 1337, MyJoinField = JoinField.Root()}; infer.Routing(parent).Should().Be("1337"); var child = new MyChild {Id = 1338, MyJoinField = JoinField.Link(parentId: "1337")}; infer.Routing(child).Should().Be("1337"); child = new MyChild {Id = 1339, MyJoinField = JoinField.Link(parent)}; infer.Routing(child).Should().Be("1337"); /** * here we index `parent` and rather than fishing out the parent id by inspecting `parent` we just pass the instance * to `Routing` which can infer the correct routing key based on the JoinField property on the instance */ var indexResponse = client.Index(parent, i => i.Routing(Routing.From(parent))); indexResponse.ApiCall.Uri.Query.Should().Contain("routing=1337"); /** * The same goes for when we index a child, we can pass the instance directly to `Routing` and OSC will use the parent id * already specified on `child`. Here we use the static import `using static OpenSearch.Client.Infer` and it's `Route()` static method to * create an instance of `Routing` */ indexResponse = client.Index(child, i => i.Routing(Route(child))); indexResponse.ApiCall.Uri.Query.Should().Contain("routing=1337"); /** You can always override the default inferred routing though */ indexResponse = client.Index(child, i => i.Routing("explicit")); indexResponse.ApiCall.Uri.Query.Should().Contain("routing=explicit"); indexResponse = client.Index(child, i => i.Routing(null)); indexResponse.ApiCall.Uri.Query.Should().NotContain("routing"); var indexRequest = new IndexRequest(child) { Routing = Route(child) } ; indexResponse = client.Index(indexRequest); indexResponse.ApiCall.Uri.Query.Should().Contain("routing=1337"); /** * Its important to note that the routing is resolved at request time, not instantiation time * here we update the `child`'s `JoinField` after already creating the index request for `child` */ child.MyJoinField = JoinField.Link(parentId: "something-else"); indexResponse = client.Index(indexRequest); indexResponse.ApiCall.Uri.Query.Should().Contain("routing=something-else"); } /** [NOTE] * -- * If you use multiple levels of parent and child relations e.g `A => B => C`, when you index `C`, you * need to provide the id of `A` as the routing key *but* the id of `B` to set up the relation on the join field. * In this case, OSC `JoinRouting` helper is unable to resolve to the id of `A` and will return the id of `B`. * */ } }