/*
* Licensed 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.
*/
package com.addthis.hydra.data.filter.value;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaFileObject;
import java.io.IOException;
import java.net.MalformedURLException;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import com.addthis.bundle.value.ValueObject;
import com.addthis.codec.annotations.FieldConfig;
import com.addthis.codec.codables.SuperCodable;
import com.addthis.hydra.data.compiler.JavaSimpleCompiler;
import com.addthis.hydra.data.filter.eval.InputType;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This {@link AbstractValueFilter ValueFilter} <span class="hydra-summary">evaluates
* a user-defined java function.</span>.
* <p/>
* <p>The user-defined java function is expected to operate on either
* primitive values (long, double, String, byte[]) or a list
* or a map over autoboxed primitive types (Long, Double, String, byte[]).
* If the input is a list or a map then remember to set the {@link #once once}
* parameter to 'true'. The default type of the input variable is a long.
* <p/>
* <p>If the input passed into the filter is null and the input type
* is LONG or DOUBLE then the filter always returns null. If the input
* is null and the input type is neither LONG nor DOUBLE then the user
* is responsible for handling the null input.
* <p/>
* <p>By default the name of the input variable is "x".
* This name can be changed with the parameter {@link #inputName}.
* The type of the input variable is specified by the parameter
* {@link #inputType}.</p>
* <p/>
* <p>The body of the function is placed inside the {@link #body}
* parameter. This parameter is an array of Strings for the purposes
* of making it easier to write down multiline functions. You may choose
* to separate each line of your function definition into a separate
* element of the array. Or you may choose to place the function
* body into an array of a single element.</p>
* <p/>
* <p>Examples:</p>
* <pre>
* // switch statements cannot operate on long types.
* // so convert the input to an integer type.
* {eval-java {
* inputName: "longInput"
* inputTYpe: "LONG"
* outputType: "STRING"
* body: """
* int intInput = (int) longInput;
* switch (intInput) {
* case 1: return "foo";
* case 2: return "bar";
* default: return "baz";
* }
* """
* }}
*
* {eval-java {
* inputName: "input"
* inputTYpe: "STRING"
* outputType: "STRING"
* body: """return String.format("Hello %s", input);"""
* }}
*
* {eval-java {
* inputName: "input"
* inputTYpe: "LIST_STRING"
* outputType: "STRING"
* once: true
* body: """
* if (input.size() < 3) {
* return null;
* } else {
* return input.get(2);
* }
* """
* }}</pre>
*
* @user-reference
*/
public class ValueFilterEvalJava extends AbstractValueFilter implements SuperCodable {
private static final Logger log = LoggerFactory.getLogger(ValueFilterEvalJava.class);
/**
* Name of the input variable. The name
* "value" is not allowed. Default is "x".
*/
@FieldConfig(codable = true)
private String inputName = "x";
/**
* Type of the input variable. Legal values are either primitive types, list types, or
* map types. The primitive types are "STRING", "LONG", "DOUBLE", and "BYTES".
* The list types are LIST_[TYPE] where [TYPE] is one of the primitive types.
* The map types are MAP_STRING_[TYPE] where [TYPE] is one of the primitive types.
* This field is case sensitive. Default is "LONG".
*/
@FieldConfig(codable = true)
private InputType inputType = InputType.LONG;
/**
* Type of the return variable. Legal values are either primitive types, list types, or
* map types. The primitive types are "STRING", "LONG", "DOUBLE", and "BYTES".
* The list types are LIST_[TYPE] where [TYPE] is one of the primitive types.
* The map types are MAP_STRING_[TYPE] where [TYPE] is one of the primitive types.
* This field is case sensitive. Default is "LONG".
*/
@FieldConfig(codable = true)
private InputType outputType = InputType.LONG;
/**
* Function body. This field is an array of strings. The
* contents of the strings will be concatenated to form the
* body of the user-defined function. The array is only
* for the purpose of improving the user interface. You may
* place the entire contents of the function body into
* an array of a single element. This field is required.
*/
@FieldConfig(codable = true, required = true)
private String[] body;
/**
* Optional. A set of import statements that are included
* at the top of the generated class.
*/
@FieldConfig(codable = true)
private String[] imports;
/**
* If the input type is either a list type or a map type,
* then this field specifies whether to process a copy of the
* input or not. Default is true.
*/
private boolean copyInput = true;
private ValueFilter constructedFilter;
@VisibleForTesting
ValueFilterEvalJava() {}
private static final Set<String> requiredImports = new HashSet<>();
static {
requiredImports.add("import com.addthis.hydra.data.filter.eval.*;");
requiredImports.add("import com.addthis.hydra.data.filter.value.ValueFilter;");
requiredImports.add("import com.addthis.hydra.data.filter.value.AbstractValueFilter;");
requiredImports.add("import com.addthis.bundle.value.DefaultArray;");
requiredImports.add("import com.addthis.bundle.value.ValueArray;");
requiredImports.add("import com.addthis.bundle.value.ValueObject;");
requiredImports.add("import com.addthis.bundle.value.ValueFactory;");
requiredImports.add("import java.util.List;");
requiredImports.add("import java.util.Map;");
}
@Override public void postDecode() {
constructedFilter = createConstructedFilter();
}
@Override public void preEncode() {}
@Override
public ValueObject filterValue(ValueObject value) {
if (constructedFilter != null) {
return constructedFilter.filter(value);
} else {
return null;
}
}
private ValueFilter createConstructedFilter() {
StringBuffer classDecl = new StringBuffer();
String classDeclString;
UUID uuid = UUID.randomUUID();
String className = "ValueFilter" + uuid.toString().replaceAll("-", "");
for (String oneImport : requiredImports) {
classDecl.append(oneImport);
classDecl.append("\n");
}
if (imports != null) {
for (String oneImport : imports) {
if (!requiredImports.contains(oneImport)) {
classDecl.append(oneImport);
classDecl.append("\n");
}
}
}
classDecl.append("public class ");
classDecl.append(className);
classDecl.append(" extends AbstractValueFilter\n");
classDecl.append("{\n");
createConstructor(classDecl, className);
createFilterValueMethod(classDecl);
createFilterValueInternalMethod(classDecl);
classDecl.append("}\n");
classDeclString = classDecl.toString();
JavaSimpleCompiler compiler = new JavaSimpleCompiler();
boolean success;
try {
success = compiler.compile(className, classDeclString);
} catch (IOException ex) {
String msg = "Exception occurred while attempting to compile 'eval-java' filter.";
msg += ex.toString();
log.warn("Attempting to compile the following class.");
log.warn("\n" + classDeclString);
throw new IllegalStateException(msg);
}
try {
if (!success) {
throw handleCompilationError(classDeclString, compiler);
}
ValueFilter filter;
try {
filter = (ValueFilter) compiler.getDefaultInstance(className, AbstractValueFilter.class);
} catch (ClassNotFoundException | MalformedURLException |
InstantiationException | IllegalAccessException ex) {
String msg = "Exception occurred while attempting to classload 'eval-java' generated class.";
msg += ex.toString();
log.warn("Attempting to compile the following class.");
log.warn("\n" + classDeclString);
throw new IllegalStateException(msg);
}
return filter;
} finally {
compiler.cleanupFiles(className);
}
}
private IllegalStateException handleCompilationError(String classDeclString, JavaSimpleCompiler compiler) {
DiagnosticCollector<JavaFileObject> diagCollector = compiler.getDiagnostics();
StringBuilder builder = new StringBuilder();
builder.append("Error(s) occurred while attempting to compile 'eval-java'.\n");
for (Diagnostic diagnostic : diagCollector.getDiagnostics()) {
if (diagnostic.getKind().equals(Diagnostic.Kind.ERROR)) {
builder.append(diagnostic.getMessage(null));
builder.append(" at line ");
builder.append(diagnostic.getLineNumber());
builder.append(" and column ");
builder.append(diagnostic.getColumnNumber());
builder.append("\n");
}
}
log.warn("Attempting to compile the following class.");
log.warn("\n" + classDeclString);
return new IllegalStateException(builder.toString());
}
private void createFilterValueInternalMethod(StringBuffer classDecl) {
classDecl.append("private ");
classDecl.append(outputType.getTypeName());
classDecl.append(" filterValueInternal(");
classDecl.append(inputType.getTypeName());
classDecl.append(" ");
classDecl.append(inputName);
classDecl.append(")\n");
classDecl.append("{\n");
for (String line : body) {
classDecl.append(line);
classDecl.append("\n");
}
classDecl.append("}\n\n");
}
private void createFilterValueMethod(StringBuffer classDecl) {
classDecl.append("public ValueObject filterValue(ValueObject value)\n");
classDecl.append("{\n");
if (inputType == InputType.DOUBLE || inputType == InputType.LONG) {
classDecl.append("if (value == null)\n");
classDecl.append("return null;\n");
classDecl.append(inputType.getTypeName());
classDecl.append(" input = ");
} else {
classDecl.append(inputType.getTypeName());
classDecl.append(" input = (value == null) ? null : ");
}
switch (inputType.getCategory()) {
case PRIMITIVE:
classDecl.append(inputType.fromHydraAsReference("value"));
break;
case LIST:
case MAP: {
if (copyInput) {
classDecl.append(inputType.fromHydraAsCopy("value"));
} else {
classDecl.append(inputType.fromHydraAsReference("value"));
}
break;
}
}
classDecl.append(";\n");
classDecl.append(outputType.getTypeName());
classDecl.append(" output = filterValueInternal(input);\n");
classDecl.append("return ");
classDecl.append(outputType.toHydra("output"));
classDecl.append(";\n");
classDecl.append("}\n\n");
}
private void createConstructor(StringBuffer classDecl, String className) {
classDecl.append("public ");
classDecl.append(className);
classDecl.append("() {\n");
classDecl.append("setOnce(");
classDecl.append(getOnce());
classDecl.append(");\n");
classDecl.append("}\n\n");
}
public void setInputType(InputType type) {
this.inputType = type;
}
public void setBody(String[] body) {
this.body = body;
}
public void setCopyInput(boolean val) {
this.copyInput = val;
}
public void setOutputType(InputType type) {
this.outputType = type;
}
}