/* * Copyright (c) 2017 OBiBa. All rights reserved. * * This program and the accompanying materials * are made available under the terms of the GNU Public License v3.0. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.obiba.magma.js; import java.lang.reflect.Array; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.Map; import java.util.SortedSet; import java.util.TreeSet; import javax.annotation.Nullable; import javax.validation.constraints.NotNull; import com.google.common.base.Predicate; import com.google.common.collect.Lists; import org.mozilla.javascript.Context; import org.mozilla.javascript.ContextAction; import org.mozilla.javascript.ContextFactory; import org.mozilla.javascript.Script; import org.mozilla.javascript.Scriptable; import org.mozilla.javascript.Undefined; import org.obiba.magma.Initialisable; import org.obiba.magma.Timestamps; import org.obiba.magma.Value; import org.obiba.magma.ValueSet; import org.obiba.magma.ValueSource; import org.obiba.magma.ValueTable; import org.obiba.magma.ValueType; import org.obiba.magma.VariableEntity; import org.obiba.magma.VectorSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Function; import com.google.common.base.Stopwatch; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.collect.Maps; /** * A {@code ValueSource} implementation that uses a JavaScript script to evaluate the {@code Value} to return. * <p/> * Within the JavaScript engine, {@code Value} instances are represented by {@code ScriptableValue} host objects. * <p/> * This class implements {@code Initialisable}. During the {@code #initialise()} method, the provided script is * compiled. Any compile error is thrown as a {@code EvaluatorException} which contains the details of the error. * * @see ScriptableValue */ public class JavascriptValueSource implements ValueSource, VectorSource, Initialisable { private static final Logger log = LoggerFactory.getLogger(JavascriptValueSource.class); @NotNull private final ValueType type; @NotNull private final String script; private String scriptName = "customScript"; // need to be transient because of XML serialization @SuppressWarnings("TransientFieldInNonSerializableClass") private transient Script compiledScript; @SuppressWarnings("ConstantConditions") public JavascriptValueSource(@NotNull ValueType type, @NotNull String script) { if(type == null) throw new IllegalArgumentException("type cannot be null"); if(script == null) throw new IllegalArgumentException("script cannot be null"); this.type = type; this.script = script; } public String getScriptName() { return scriptName; } public void setScriptName(String name) { scriptName = name; } @NotNull public String getScript() { return script; } @NotNull @Override public Value getValue(ValueSet valueSet) { initialiseIfNot(); Stopwatch stopwatch = Stopwatch.createStarted(); Value value = (Value) ContextFactory.getGlobal().call(new ValueSetEvaluationContextAction(valueSet)); log.trace("ValueSet evaluation of {} in {}", getScriptName(), stopwatch); return value; } @Override public boolean supportVectorSource() { return true; } @NotNull @Override public VectorSource asVectorSource() { return this; } @Override @SuppressWarnings("unchecked") public Iterable<Value> getValues(SortedSet<VariableEntity> entities) { initialiseIfNot(); Stopwatch stopwatch = Stopwatch.createStarted(); Iterable<Value> values = (Iterable<Value>) ContextFactory.getGlobal() .call(new ValueVectorEvaluationContextAction(entities)); log.trace("Vector evaluation of {} in {}", getScriptName(), stopwatch); return values; } @NotNull @Override public ValueType getValueType() { return type; } @Override public void initialise() { } protected void initialiseIfNot() { if(compiledScript == null) { try { compiledScript = (Script) ContextFactory.getGlobal().call(new ContextAction() { @Override public Object run(Context context) { String optLevel = System.getProperty("rhino.opt.level"); if (optLevel != null) { try { context.setOptimizationLevel(Integer.parseInt(optLevel)); } catch(Exception e) {} } return context.compileString(getScript(), getScriptName(), 1, null); } }); } catch(Exception e) { log.error("Script compilation failed: {}", getScript(), e); throw new MagmaJsRuntimeException("Script compilation failed: " + e.getMessage(), e); } } } protected boolean isSequence() { return false; } /** * This method is invoked before evaluating the script. It provides a chance for derived classes to initialise values * within the context. This method will add the current {@code ValueSet} as a {@code ThreadLocal} variable with * {@code ValueSet#class} as its key. This allows other classes to have access to the current {@code ValueSet} during * the script's execution. * <p/> * Classes overriding this method must call their super class' method * * @param ctx the current context * @param scope the scope of execution of this script */ protected void enterContext(MagmaContext ctx, Scriptable scope) { } protected void exitContext(MagmaContext ctx) { } private abstract class AbstractEvaluationContextAction implements ContextAction { @Override public Object run(Context ctx) { MagmaContext context = MagmaContext.asMagmaContext(ctx); // Don't pollute the global scope Scriptable scope = context.newLocalScope(); enterContext(context, scope); try { return eval(context, scope); } finally { exitContext(context); } } void enterContext(MagmaContext context, Scriptable scope) { JavascriptValueSource.this.enterContext(context, scope); } void exitContext(MagmaContext context) { JavascriptValueSource.this.exitContext(context); } abstract Object eval(MagmaContext context, Scriptable scope); Value asValue(Object value) { Value result; if(value == null || value instanceof Undefined) { result = isSequence() ? getValueType().nullSequence() : getValueType().nullValue(); } else if(value instanceof ScriptableValue) { ScriptableValue scriptableValue = (ScriptableValue) value; result = scriptableValue.getValue(); if(!result.isSequence() && isSequence()) { result = asValueSequence(result); } else if (result.isSequence() && !isSequence()) { result = result.asSequence().getValues().stream().filter(input -> !input.isNull()) // .findFirst().orElseGet(() -> getValueType().nullValue()); } } else { result = isSequence() ? asValueSequence(value) : getValueType().valueOf(Rhino.fixRhinoNumber(value)); } if(result.getValueType() != getValueType()) { // Convert types try { result = getValueType().convert(result); } catch(RuntimeException e) { throw new MagmaJsRuntimeException( "Cannot convert value '" + result + "' to type '" + getValueType().getName() + "'", e); } } return result; } Value asValueSequence(Object value) { Value result = null; if(value.getClass().isArray()) { int length = Array.getLength(value); Collection<Value> values = new ArrayList<>(length); for(int i = 0; i < length; i++) { Object v = Rhino.fixRhinoNumber(Array.get(value, i)); values.add(getValueType().valueOf(v)); } result = getValueType().sequenceOf(values); } else { // Build a singleton sequence result = getValueType().sequenceOf(ImmutableList.of(getValueType().valueOf(Rhino.fixRhinoNumber(value)))); } return result; } } private final class ValueSetEvaluationContextAction extends AbstractEvaluationContextAction { private final ValueSet valueSet; ValueSetEvaluationContextAction(ValueSet valueSet) { this.valueSet = valueSet; } @Override void enterContext(MagmaContext context, Scriptable scope) { context.push(ValueSet.class, valueSet); context.push(ValueTable.class, valueSet.getValueTable()); context.push(VariableEntity.class, valueSet.getVariableEntity()); super.enterContext(context, scope); } @Override void exitContext(MagmaContext context) { context.pop(VariableEntity.class); context.pop(ValueTable.class); context.pop(ValueSet.class); super.exitContext(context); } @Override Object eval(MagmaContext context, Scriptable scope) { return asValue(compiledScript.exec(context, scope)); } } private final class ValueVectorEvaluationContextAction extends AbstractEvaluationContextAction { @Nullable private final SortedSet<VariableEntity> entities; private final VectorCache vectorCache = new VectorCache(); ValueVectorEvaluationContextAction(@Nullable SortedSet<VariableEntity> entities) { this.entities = entities; } SortedSet<VariableEntity> getEntities(MagmaContext context) { return entities == null ? new TreeSet<>(context.peek(ValueTable.class).getVariableEntities()) : entities; } @Override void enterContext(MagmaContext context, Scriptable scope) { super.enterContext(context, scope); context.push(SortedSet.class, getEntities(context)); context.push(VectorCache.class, vectorCache); } @Override void exitContext(MagmaContext context) { super.exitContext(context); context.pop(SortedSet.class); context.pop(VectorCache.class); } @Override Object eval(MagmaContext context, Scriptable scope) { return Iterables.transform(getEntities(context), new VectorEvaluationFunction(context, scope)); } private class VectorEvaluationFunction implements Function<VariableEntity, Value> { private final MagmaContext context; private final Scriptable scope; private VectorEvaluationFunction(MagmaContext context, Scriptable scope) { this.context = context; this.scope = scope; } @Override public Value apply(VariableEntity variableEntity) { Stopwatch stopwatch = Stopwatch.createStarted(); try { initContext(variableEntity); return asValue(compiledScript.exec(context, scope)); } finally { cleanContext(); log.trace("Finish {} eval in {}", variableEntity, stopwatch); } } /** * We have to set the current thread's context because this code will be executed outside of the ContextAction */ private void initContext(VariableEntity variableEntity) { ContextFactory.getGlobal().enterContext(context); JavascriptValueSource.this.enterContext(context, scope); context.push(VectorCache.class, vectorCache); context.push(SortedSet.class, entities); context.push(VariableEntity.class, variableEntity); } private void cleanContext() { JavascriptValueSource.this.exitContext(context); context.pop(VectorCache.class).next(); context.pop(SortedSet.class); context.pop(VariableEntity.class); Context.exit(); } } } public static class VectorCache { private final Map<VectorSource, VectorHolder<Value>> vectors = Maps.newHashMap(); private VectorHolder<Timestamps> timestampsVector; // Holds the current "row" of the evaluation. private int index = 0; void next() { index++; } // Returns the value of the current "row" for the specified vector @SuppressWarnings("unchecked") public Value get(MagmaContext context, VectorSource source) { VectorHolder<Value> holder = vectors.get(source); if(holder == null) { holder = new VectorHolder<>(source.getValues(context.peek(SortedSet.class)).iterator()); vectors.put(source, holder); } return holder.get(index); } public Timestamps get(MagmaContext context, ValueTable table) { if (timestampsVector == null) { timestampsVector = new VectorHolder<>(table.getValueSetTimestamps(context.peek(SortedSet.class)).iterator()); } return timestampsVector.get(index); } } private static class VectorHolder<T> { private final Iterator<T> values; // The index of the value returned by values.next(); private int nextIndex = 0; // Value of nextIndex - 1 (null after vector) private T currentValue; VectorHolder(Iterator<T> values) { this.values = values; } /** * Returns the value of the "row" for this vector. This method will advance the iterator until we reach the * requested row. This is required because during evaluation, not all vectors involved in a script are incremented * during evaluation (due to 'if' statements in the script). * <p/> * For example, in the following script: * <p/> * <pre> * $('VAR1') ? $('VAR2') : $('VAR3') * * <pre> * vectors for VAR2 and VAR3 are not incremented at the same "rate" as VAR1. */ T get(int index) { if(index < 0) throw new IllegalArgumentException("index must be >= 0"); // Increment the iterator until we reach the requested row while(nextIndex <= index) { currentValue = values.next(); nextIndex++; } return currentValue; } } }