0

(Understanding OpenSearch/ElasticSearch is not important for this question, but the below information provides some context). I am using the new opensearch-java (version 2.4.0) library to interact with the OpenSearch cluster. I am trying to build the TypeMapping using Builder pattern, so that I can pass these properties mapping when creating a new index in OpenSearch like so:

// client will call this method when creating index.
public void createIndex(String name, TypeMapping typeMappings) {
    CreateIndexRequest createIndexRequest = new CreateIndexRequest.Builder()
                .index(name)
                .mappings(typeMappings)
                .build();
    createIndex(openSearchClient, createIndexRequest);
}
private static void createIndex(OpenSearchClient osClient, CreateIndexRequest createIndexRequest) {
    osClient.indices().create(createIndexRequest);
}

I have the following code to use the Builder pattern to build the TypeMapping.

package test;

import java.util.Map;
import org.opensearch.client.opensearch._types.mapping.Property;
import org.opensearch.client.opensearch._types.mapping.TypeMapping;
import org.opensearch.client.opensearch._types.mapping.DynamicMapping;

 
public class TypeMappingBuilder {
    private DynamicMapping dynamic;
    private final Map<String, Property> properties;

    public TypeMappingBuilder() {
        this.properties = new HashMap<>();
    }

    public TypeMappingBuilder disableDynamic() {
        this.mapping = DynamicMapping.Strict;
        return this;
    }
    
    public TypeMappingBuilder putTypeProperty(
            String name, 
            Property.Kind type, 
            boolean shouldIndex, 
            Map<String, Property> subTypeProperties) {
        this.properties.put(name, getTypeProperty(type, shouldIndex, subTypeProperties));
        return this;
    }

    private Property getTypeProperty(
            Property.Kind type, 
            boolean shouldIndex, 
            Map<String, Property> subTypeProperties) {
        if (type == Property.Kind.Text) {
// Here, in (p -> p), p is the TextProperty.Builder() provided by OpenSearch Java client
            return new Property.Builder().text(p -> p.index(shouldIndex).fields(subTypeProperties)).build();
        }
        if (type == Property.Kind.Keyword) {
// Here, in (p -> p), p is the KeywordProperty.Builder() provided by OpenSearch Java client
            return new Property.Builder().keyword(p -> p.index(shouldIndex).fields(subTypeProperties)).build();
        }
//...
//...
//... and so forth, there are more than 20 of the Property.Kind values where I can use the if condition
    }

    public TypeMapping build() {
        return new TypeMapping.Builder()
                .dynamic(dynamic)
                .properties(properties)
                .build();
    }
}

The issue is that too many if statement here (in method getTypeProperty) will cause code smell and also if a new type is added in the future, then I have to update getTypeProperty method to apply for the new type. Also, in this new Property.Builder().{specificType}({specificTypeBuilder}).build(), I cannot use polymorphism as there is different method names for each {specificType} and different type Builder for each {specificTypeBuilder}. I don't see any other options than to use if statements to create different Property based on a specific type. I thought of using static Map like so and access specific Property from the Map, but would only work if I didn't have subTypeProperties to put in for sub fields (but, it will have more memory and type overhead):

Map<PropertyHolder, Property> propertyMap = ImmutableMap.<PropertyHolder, Property>builder()
            .put(new PropertyHolder(Property.Kind.Text, true), new Property.Builder()
                    .text(p -> p.index(true)).build())
            .put(new PropertyHolder(Property.Kind.Text, false), new Property.Builder()
                    .text(p -> p.index(false)).build())
            .put(new PropertyHolder(Property.Kind.Keyword, true), new Property.Builder()
                    .keyword(p -> p.index(true)).build())
            .put(new PropertyHolder(Property.Kind.Keyword, false), new Property.Builder()
                    .keyword(p -> p.index(false)).build())
            ...
            ...
            ...
            .build();

    final class PropertyHolder {
        private final Property.Kind type;
        private final boolean index;

        private PropertyHolder(Property.Kind type, boolean index) {
            this.type = type;
            this.index = index;
        }

        public static Property getTypeProperty(Property.Kind type, boolean shouldIndex) {
            return propertyMap.get(new PropertyHolder(type, shouldIndex));
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            PropertyHolder that = (PropertyHolder) o;
            return type == that.type && index == that.index;
        }

        @Override
        public int hashCode() {
            return Objects.hash(type, index);
        }
    }

Another option that can actually work was to create my own enum and have a method defined for each enum value that returns a specific Property based on the enum value (Strategy pattern). But, that would be an abuse of enums and probably code smell. Here is what I am talking about:

public enum Type {
        TEXT {
            @Override
            public Property getTypeProperty(boolean shouldIndex, Map<String, Property> subTypeProperties) {
                return new Property.Builder().text(p -> p.index(shouldIndex).fields(subTypeProperties)).build();
            }
        },
        KEYWORD {
            @Override
            public Property getTypeProperty(boolean shouldIndex, Map<String, Property> subTypeProperties) {
                return new Property.Builder().keyword(p -> p.index(shouldIndex).fields(subTypeProperties)).build();
            }
        },
        ...,
        ...,
        ...;

        public abstract Property getTypeProperty(boolean shouldIndex, Map<String, Property> subTypeProperties);
    }

What design pattern can I use in this scenario to avoid code smell? Thank you!

EDIT:

In the scenario that I am working on, the client can either create TypeMapping using TypeMappingBuilder and then, pass that to the createIndex request, like so (added based on the answer mentioned by BionicCode):

TypeMapping typeMappings = new TypeMappingBuilder();
builder.putTextProperty(...)
    .putTextProperty(...)
    .putKeywordProperty(...)
    .build();
createIndex(indexName, typeMappings);

or using MappingFactory like so:

private static final Map<String, Property> MAPPINGS = ImmutableMap.<String, Property>builder()
        .putAll(MappingFactory.getMapping(Memory.class))
        .putAll(MappingFactory.getMapping(Cpu.class))
...
...
...
        .build();
TypeMapping typeMappings = new TypeMapping.Builder().properties(MAPPINGS).build();
createIndex(indexName, typeMappings);

Here, the Memory.class or Cpu.class POJO looks like this:

class Memory {
    @TypeAnnotation(name = "mem_type", type = Type.KEYWORD, index = FALSE)
    private final String memType;

    @TypeAnnotation(name = "bytes", type = Type.LONG, index = true)
    private final long bytes;
...
...
...
}

And, the TypeAnnotation in my library looks like this:

@interface TypeAnnotation {
    String name();
    Type type();
    boolean index();

    public static final Map<Type, Factory> TYPE_FACTORY_MAP = ImmutableMap.<Type, Factory>builder()
        .put(Type.TEXT, new TextFactory())
        .put(Type.KEYWORD, new KeywordFactory())
...
...
...
        .build();
}

Now, in the scenario where the Client uses TypeMappingBuilder, I can setup putKeywordProperty, putTextProperty, etc. methods for the client to use to add specific property to the properties Map before building TypeMapping. But, when the Client calls MappingFactory.getMapping(Memory.class), the current implementation for getMapping(Class) method is like so:

class MappingFactory {
    public static Map<String, Property> getMapping(Class<?> clazz) {
        Walker walker = new Walker();
        // this will walk the fields and add each Property to Walker properties Map. 
        ReflectionUtils.doWithFields(clazz, walker);
        return walker.getMapping();
    }

    // FieldCallback is provided by Spring framework as part of it's ReflectionUtils
    private static final class Walker implements FieldCallback {
        private final ImmutableMap.Builder<String, Property> properties = ImmutableMap.builder();
        public ImmutableMap<String, Property> getMapping() {
            return this.properties.build();
        }

        @Override
        public void doWith(java.lang.reflect.Field field) {
            TypeAnnotation typeAnnotation = (TypeAnnotation) field.getAnnotation(TypeAnnotation.class);
            String name = typeAnnotation.name();
            Type type = typeAnnotation.type();
            boolean index = typeAnnotation.index();
            // map defined in TypeAnnotation that contains <Type, Factory> and will return a specific property Factory (implements Factory) that you can use to create Property.
            Factory factory = TYPE_FACTORY_MAP.get(type);
            Property property = factory.getProperty(index);
            this.properties.put(name, property);
        }
    }
}
interface Factory {
    Property getProperty(boolean index);
    Property getProperty(boolean index, Map<String, Property> subProperties);
}
class KeywordFactory implements Factory {
    @Override
    public Property getProperty(boolean index) {
        return new Property.Builder().keyword(p -> p.index(index)).build();
    }

    @Override
    public Property getProperty(boolean index, Map<String, Property> subProperties) {
        return new Property.Builder().keyword(p -> p.index(index).fields(subProperties)).build();
    }
}

The issue is that I have to use either if/else or switch or factory pattern (I am using factory pattern here in doWith method and storing each factory in a Map<Type, Factory> that is defined in TypeAnnotation). Is there any other solution or a solution that is better when the client have the options to either use TypeMappingBuilder or MappingFactory? Thanks!

Chintan
  • 59
  • 1
  • 2
  • 9
  • Is using reflection an option? – Bohemian Jul 26 '23 at 23:06
  • Reflection is an option and I am actually using reflection combined with FieldCallback from Spring, where each fields are annotated where you can also specify Type enum. – Chintan Aug 02 '23 at 15:06
  • So FieldCallback is a library type or do you own it? And where is doWith(Field) called? –  Aug 02 '23 at 20:16
  • `ReflectionUtils.FieldCallback` is an interface provided by Spring framework [here](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/ReflectionUtils.FieldCallback.html). When you call `ReflectionUtils.doWithFields(clazz, walker)` (I have called it in `MappingFactory.getMapping(clazz)`), the method will get all the fields declared in the `clazz`, and call `walker.doWith(field)` on each of those fields (basically, it will loop through fields). – Chintan Aug 02 '23 at 20:25
  • Here is what the [doc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/ReflectionUtils.html#doWithFields(java.lang.Class,org.springframework.util.ReflectionUtils.FieldCallback)) says about `ReflectionUtils.doWithFields`: "Invoke the given callback on all fields in the target class, going up the class hierarchy to get all declared fields." – Chintan Aug 02 '23 at 20:27
  • Thank you for the input. May i ask why you went the annotation+reflection route? I assume your mapper is supposed to work for any type and not only a handful of known types? if this isn't a requirement I would not do that as it would introduce the slow reflection along with the not very graceful implementation details. –  Aug 02 '23 at 20:56
  • So, the library that I am working at is using really old version of ElasticSearch and it allows using both annotation+reflection and `TypeMappingBuilder`. I am migrating its usage to OpenSearch, and would like to keep the client using both approaches in the beginning until we deprecate using the annotation+reflection option (`MappingFactory`). And, the mapper is supposed to work for any types that we support (basically, handful of types that we use from what OpenSearch/ElasticSearch supports). Types that we support are defined by enum `Type`. – Chintan Aug 02 '23 at 21:05
  • 1
    Thanks for the information. In this case there is nothing you can do to improve the annotations version. Java annotations are very limited, for example you can't add methods to an annotation or use inheritance like you could do in C#. So using a hash table to lookup the factory or alternatively a lambda expression is your only way. However, you can still improve the `MappingFactory`. But only if you have limited types that are expected. For example if `Memory` and `Cpu` are the only classes then you can improve `MappingFactory`. If you want to support any type then there is nothing to improve. –  Aug 02 '23 at 21:50
  • Make sense. Thank you! – Chintan Aug 02 '23 at 21:56
  • 1
    The problem is that the actual Property type is only available as meta data via the annotation. There is no other way to provide this information to the Walker when annotations are used - unless your system is not open for any types or can introduce conventions, for example all participating classes must implement a PropertyProvider interface in addition to the annotation. But I guess this is not possible because you have to work with legacy code if I understood correctly. A PropertyProvider would be a graceful solution to eliminate the hash map. –  Aug 02 '23 at 21:56
  • 1
    In case of the TypeMappingBuilder, the type information was provided as a parameter. That enabled refactoring to improve the API. that's a totally different scenario, but a good example why you have to use annotations sparingly. Aside from reflection they introduce other negative side effects in terms of class design. –  Aug 02 '23 at 21:59

2 Answers2

2

Usually, the caller of your API knows all the details of the context. You only have to expose a complete path for each supported context.
As a result the client code now explicitly selects the method/execution path instead of selecting an enum value.
This will make your API bigger opposed to a more generic API. But if it is about code paths, generic APIs are not useful (see your problem).
generic APIs only help in terms of type compatibility (input/output).

In terms of execution paths, you always need the client of your API to select an execution path: either in form of a parameter (you current choice) or by explicitly invoking the desired functionality.
The latter provides:

  • a much cleaner API that allows the client code to communicates the intend better: it's more convenient to read and understand the client code because we are now enabled to read method names opposed to having to read through an argument list. Argument names are only useful if the reader knows about their purpose. To know this he must read the API documentation. Method names on the other hand directly communicate the purpose of the operation without the requirement to read any documentation.
  • it also reduces the parameter count of the method: in your case the enum parameter is converted to a method name.
  • and as a bonus you avoid extra documentation: the method names now clearly communicate the purpose of the method (if the names are chosen properly).
  • you eliminate the Property.Kind enum (additional type only introduced to select the execution path).
  • you eliminate the terrible to maintain cascades of if-statements and switch cases: this code breaks the Open-Close principle and therefore does not make your code/class extensible.

The solution is to allow the builder to explicitly fork the execution path. This eliminates the Property.Kind enum (previously used to identify the selected execution path) and the getTypeProperty method (previously used to execute the specialized execution path). Now the client of the TypeMappingBuilder API is responsible to select the execution path explicitly by calling the related method instead of passing the enum identifier.

The result is a more verbose client code (in terms of enhanced readability) and a more convenient API.
It's the only solution that improves the API, for example in contrast to alternative solutions like looking up instances in a hash table.
A hash table solution only works for object lookup. A hash table still won't solve more complex scenarios. In addition a hash table solution adds more memory and types overhead - while we usually can live with the memory overhead, the additional types we have to implement add unnecessary complexity).

So instead of adding more complexity to your internal code, simply clean up the API:

Usage example:

// Easier to read client code, where the intend is communicated via method names instead of argument names. 
// Even the argument name is meaning less 
// if the reader does not know that the argument is an actual execution path selector.
// To know this, he would have to read the API documentation.
// The method name allows the reader to instantly know the purpose 
// and the author which method to invoke.
TypeMappingBuilder builder = new TypeMappingBuilder();
builder.putTextProperty(...)
  .putTextProperty(...)
  .putKeywordProperty(...);

TypeMappingBuilder.java

public class TypeMappingBuilder {
    private DynamicMapping dynamic;
    private final Map<String, Property> properties;

    public TypeMappingBuilder() {
        this.properties = new HashMap<>();
    }

    public TypeMappingBuilder disableDynamic() {
        this.mapping = DynamicMapping.Strict;
        return this;
    }
    
    public TypeMappingBuilder putTextProperty(
            String name, 
            boolean shouldIndex, 
            Map<String, Property> subTypeProperties) {

        Property textProperty = new Property.Builder().text(p -> p.index(shouldIndex).fields(subTypeProperties)).build();
        this.properties.put(name, textProperty);

        return this;
    }
    
    public TypeMappingBuilder putKeywordProperty(
            String name, 
            boolean shouldIndex, 
            Map<String, Property> subTypeProperties) {

        Property keywordProperty = new Property.Builder().keyword(p -> p.index(shouldIndex).fields(subTypeProperties)).build();
        this.properties.put(name, keywordProperty);

        return this;
    }

    public TypeMapping build() {
        return new TypeMapping.Builder()
                .dynamic(dynamic)
                .properties(properties)
                .build();
    }
}
  • This is a way better solution. Thank you! As I have TypeMappingBuilder which the client uses, I can explicitly provide methods for each different types. Now, the client has the ability to either use TypeMappingBuilder, but the client also have the ability to use an Annotation TypeAnnotation (it has Type field) and annotate it's POJO's field with this annotation. I have MappingFactory with method getMapping(Class) to retrieve mappings based on the POJO. So, I have to use switch or if/else to check which Property to build. Should I use Factory pattern to create Factories for different Type's? – Chintan Aug 02 '23 at 15:34
  • The client has the ability to either use TypeMappingBuilder or MappingFactory. In MappingFactory, I have to create Property based on the Type enum attached to the Annotation TypeAnnotation in a specific POJO. In this scenario, there is no other option than to either use if/else or switch or factory pattern with Map. Is there any other better solution in this case or does it change the solution you provided if the client have either the option to use TypeMappingBuilder or MappingFactory? – Chintan Aug 02 '23 at 15:39
  • May be you could update your question with a small example especially to show the switch you are asking about? From what I understood I would suggest to implement MappingFactory as a generic type. I don't think that a factory will solve your problem. But I don't have enough details. Would you mind to update your question? This would be really helpful. –  Aug 02 '23 at 15:49
  • Yeah, will do it. – Chintan Aug 02 '23 at 15:51
  • I have updated the question and the new information is under **EDIT:**. Let me know if something is unclear. – Chintan Aug 02 '23 at 17:09
  • 1
    Thank you. I will update my answer accordingly. –  Aug 02 '23 at 20:17
1

If you are willing to sacrifice space, you could pre populate a hash map of Property.Kind mapping to TypeMapBuilder.

Now in the method, just need a single return map.get(type);

displayName
  • 13,888
  • 8
  • 60
  • 75