diff --git a/extensions/js/build.gradle b/extensions/js/build.gradle new file mode 100644 index 000000000..b4bf4ab70 --- /dev/null +++ b/extensions/js/build.gradle @@ -0,0 +1,5 @@ +dependencies { + compile project(':doov-core') + compile project(':doov-assertions') + compile group: 'org.webjars', name: 'momentjs', version: '2.10.3' +} diff --git a/extensions/js/src/main/java/io/doov/js/ast/AstJavascriptVisitor.java b/extensions/js/src/main/java/io/doov/js/ast/AstJavascriptVisitor.java new file mode 100644 index 000000000..a45c41010 --- /dev/null +++ b/extensions/js/src/main/java/io/doov/js/ast/AstJavascriptVisitor.java @@ -0,0 +1,526 @@ +/* + * Copyright (C) by Courtanet, All Rights Reserved. + */ +package io.doov.js.ast; + +import static io.doov.core.dsl.meta.DefaultOperator.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import io.doov.core.dsl.meta.*; +import io.doov.core.dsl.meta.ast.AbstractAstVisitor; +import io.doov.core.dsl.meta.i18n.ResourceProvider; +import io.doov.core.dsl.meta.predicate.BinaryPredicateMetadata; +import io.doov.core.dsl.meta.predicate.LeafPredicateMetadata; +import io.doov.core.dsl.meta.predicate.NaryPredicateMetadata; +import io.doov.core.dsl.meta.predicate.UnaryPredicateMetadata; +import org.apache.commons.lang3.StringUtils; + +public class AstJavascriptVisitor extends AbstractAstVisitor { + + private final OutputStream ops; + protected final ResourceProvider bundle; + protected final Locale locale; + + private int parenthese_depth = 0; // define the number of parenthesis to close before ending the rule rewriting + private int start_with_count = 0; // define the number of 'start_with' rule used for closing parenthesis purpose + private int end_with_count = 0; // define the number of 'start_with' rule used for closing parenthesis purpose + private int use_regexp = 0; // boolean as an int to know if we are in a regexp for closing parenthesis purpose + private int is_match = 0; // boolean as an int to know if we are in a matching rule for closing parenthesis purpose + + public AstJavascriptVisitor(OutputStream ops, ResourceProvider bundle, Locale locale) { + this.ops = ops; + this.bundle = bundle; + this.locale = locale; + } + + @Override + public void startBinary(BinaryPredicateMetadata metadata, int depth) { + write("("); + } + + @Override + public void afterChildBinary(BinaryPredicateMetadata metadata, Metadata child, boolean hasNext, int depth) { + if (hasNext) { + switch ((DefaultOperator) metadata.getOperator()) { + case and: + write(" && "); + break; + case or: + write(" || "); + break; + case xor: + manageXOR(metadata.getLeft(), metadata.getRight()); + break; + case greater_than: + write(" > "); + break; + case greater_or_equals: + write(" >= "); + break; + case lesser_than: + write(" < "); + break; + case lesser_or_equals: + write(" <= "); + case equals: + write(" == "); + break; + case not_equals: + write(" != "); + break; + } + } + } + + @Override + public void endBinary(BinaryPredicateMetadata metadata, int depth) { + write(")"); + } + + @Override + public void startNary(NaryPredicateMetadata metadata, int depth) { + if (metadata.getOperator() == match_none) { + write("!"); // for predicate [a,b,c] will translate as (!a && !b && !c) + } + if (metadata.getOperator() == count || metadata.getOperator() == sum) { + write("["); // opening a list to use count or sum on + } + if (metadata.getOperator() == min) { + write("Math.min.apply(null,["); // using JS Math module to apply min on a list, opening the list + } + } + + @Override + public void afterChildNary(NaryPredicateMetadata metadata, Metadata child, boolean hasNext, int depth) { + if (hasNext) { + switch ((DefaultOperator) metadata.getOperator()) { + case match_any: + write(" || "); // using 'or' operator to match any of the predicate given + break; + case match_all: + write(" && "); // using 'and' operator for match all + break; + case match_none: + write(" && !"); // 'and not' for match none + break; + case min: + case sum: + case count: + write(", "); // separating the list values + break; + } + } + } + + @Override + public void endNary(NaryPredicateMetadata metadata, int depth) { + if (metadata.getOperator() == count) { + write("].reduce(function(acc,val){if(val){return acc+1;}return acc;},0)"); + } // using reduce method to count with an accumulator + if (metadata.getOperator() == min) { + write("])"); // closing the value list + } + if (metadata.getOperator() == sum) { + write("].reduce(function(acc,val){ return acc+val;},0)"); + } // using reduce method to sum the value + } + + @Override + public void startLeaf(LeafPredicateMetadata metadata, int depth) { + ArrayDeque stack = new ArrayDeque<>(); //using arrayDeque to store the fields + metadata.elements().forEach(element -> { + switch (element.getType()) { + case OPERATOR: + if (element.getReadable() == age_at + || element.getReadable() == as_a_number // checking for special cases + || element.getReadable() == as_string || element.getReadable() == before + || element.getReadable() == before_or_equals || element.getReadable() == after + || element.getReadable() == after_or_equals) { + stack.addFirst(element); + } else { + stack.add(element); + } + break; + case STRING_VALUE: + case FIELD: + case VALUE: + case TEMPORAL_UNIT: + stack.add(element); + break; + case UNKNOWN: + write("/* Unknown " + element.toString() + "*/"); + break; + case PARENTHESIS_LEFT: + case PARENTHESIS_RIGHT: + write("/*" + element.toString() + "*/"); + break; + } + }); + manageStack(stack); + while (parenthese_depth > 0) { + write(")"); + parenthese_depth--; + } + } + + @Override + public void beforeChildUnary(UnaryPredicateMetadata metadata, Metadata child, int depth) { + manageOperator((DefaultOperator) metadata.getOperator(), null); + + } + + @Override + public void endUnary(UnaryPredicateMetadata metadata, int depth) { + write(")"); + } + + + /** + * This function manage the different parameters of the predicate + * + * @param stack A deque of the predicate parameters + */ + private void manageStack(ArrayDeque stack) { + while (stack.size() > 0) { + Element e = stack.pollFirst(); + switch (e.getType()) { + case FIELD: + write(e.toString()); + break; + case OPERATOR: + manageOperator((DefaultOperator) e.getReadable(), stack); + break; + case VALUE: + if (StringUtils.isNumeric(e.toString())) { + write(e.toString()); + } else { + write("\'" + e.toString() + "\'"); + } + break; + case STRING_VALUE: + if (use_regexp == 1) { + String tmp = e.toString(); + if (is_match == 1) { + is_match = 0; + } else { + tmp = formatRegexp(tmp); + } + write(tmp); + if (start_with_count > 0) { + write(".*"); + start_with_count--; + } else if (end_with_count > 0) { + write("$"); + end_with_count--; + } + write("/"); + use_regexp = 0; + } else { + write("\'" + e.toString() + "\'"); + } + break; + case PARENTHESIS_LEFT: + case PARENTHESIS_RIGHT: + case UNKNOWN: + break; + } + } + } + + /** + * this method will write the javascript translation for the operator of the predicate + * + * @param element the default operator of the LeafMetadata + * @param stack the deque of the parameters not translated yet to Javascript predicate + */ + private void manageOperator(DefaultOperator element, ArrayDeque stack) { + ArrayDeque stackTmp = new ArrayDeque<>(); + switch (element) { + case rule: + case validate: + case empty: + case as: + break; + case with: + manageStack(stack); + break; + case as_a_number: + if (stack.size() > 0) { + write("parseInt("); + write(stack.pollFirst().toString()); + write(")"); + } + break; + case as_string: + if (stack.size() > 0) { + write("String(" + stack.pollFirst().toString() + ")"); + } + break; + case not: + write("!("); + break; + case always_true: + write("true"); + break; + case always_false: + write("false"); + break; + case times: + write(" * "); + break; + case equals: + write(" === "); + stackTmp.add(stack.pollFirst()); + manageStack(stackTmp); + break; + case not_equals: + write(" !== "); + if (stack != null) { + write(stack.pollFirst().toString()); + } else if (stack != null) { + write(stack.pollFirst().toString()); + } + break; + case is_null: + write(" === ( null || undefined || \"\" ) "); + break; + case is_not_null: + write(" !== ( null || undefined || \"\" ) "); + break; + case minus: + write(".subtract(" + stack.pollFirst().toString() + "," + + "\'" + stack.pollFirst().toString() + "\')"); + break; + case plus: + write(".add(" + stack.pollFirst() + "," + + "\'" + stack.pollFirst().toString() + "\')"); + break; + case after: + write("moment(" + stack.pollFirst().toString() + "" + + ").isAfter(moment(" + stack.pollFirst().toString() + ")"); + parenthese_depth++; + break; + case after_or_equals: + write("moment(" + stack.pollFirst().toString() + "" + + ").isSameOrAfter(moment(" + stack.pollFirst().toString() + ")"); + parenthese_depth++; + break; + case age_at: + write("Math.round(Math.abs(moment("); // using Math.round(...) + stackTmp.add(stack.pollFirst()); // ex : diff(31may,31may + 1month) = 0.96 + manageStack(stackTmp); + formatAgeAtOperator(stack); + write(").diff("); //Math.abs so the date order doesn't matter + write("moment("); + stackTmp.add(stack.pollFirst()); + manageStack(stackTmp); + write(")"); + formatAgeAtOperator(stack); + write(", \'years\')))"); + break; + case before: + write("moment(" + stack.pollFirst().toString() + "" + + ").isBefore(" + stack.pollFirst().toString()); + parenthese_depth++; + break; + case before_or_equals: + write("moment(" + stack.pollFirst().toString() + "" + + ").isSameOrBefore(" + stack.pollFirst().toString()); + parenthese_depth++; + break; + case matches: + write(".match(/"); + parenthese_depth++; + use_regexp = 1; + is_match = 1; + break; + case contains: + write(".contains(\'"); + write(stack.pollFirst().toString()); + write("\')"); + break; + case starts_with: + write(".match(/^"); + start_with_count++; + parenthese_depth++; + use_regexp = 1; + break; + case ends_with: + write(".match(/.*"); + end_with_count++; + parenthese_depth++; + use_regexp = 1; + break; + case greater_than: + write(" > "); + if (stack != null && stack.size() > 0) { + write(stack.pollFirst().toString()); + } + break; + case greater_or_equals: + write(" >= "); + if (stack != null && stack.size() > 0) { + write(stack.pollFirst().toString()); + } + break; + case is: + write(" === "); + break; + case lesser_than: + write(" < "); + if (stack != null && stack.size() > 0) { + write(stack.pollFirst().toString()); + } + break; + case lesser_or_equals: + write(" <= "); + if (stack != null && stack.size() > 0) { + write(stack.pollFirst().toString()); + } + break; + case has_not_size: + write(".length != "); + break; + case has_size: + write(".length == "); + break; + case is_empty: + write(".length == 0"); + break; + case is_not_empty: + write(".length != 0"); + break; + case length_is: + write(".length"); + break; + case today: + write("moment()"); + break; + case today_plus: + write("moment().add("); + break; + case today_minus: + write("moment().subtract("); + break; + case first_day_of_this_month: + write("moment().startOf('month')"); + break; + case first_day_of_this_year: + write("moment().startOf('year')"); + break; + case last_day_of_this_month: + write("moment().endOf('month')"); + break; + case last_day_of_this_year: + write("moment().endOf('year')"); + break; + case first_day_of_month: + write(".startOf('month')"); + break; + case first_day_of_next_month: + write("moment().add(1,'month').startOf('month')"); + break; + case first_day_of_year: + write(".startOf('year')"); + break; + case first_day_of_next_year: + write("moment().add(1,'year').startOf('year')"); + break; + case last_day_of_month: + write(".endOf('month')"); + break; + case last_day_of_year: + write(".endOf('year')"); + break; + } + + } + + + /** + * age_at operator specials cases for the parameter in moment.js + * + * @param stack the deque of the parameters not translated yet to Javascript predicate + */ + private void formatAgeAtOperator(ArrayDeque stack) { + ArrayDeque stackTmp = new ArrayDeque<>(); + if (stack.getFirst().getReadable() == with || stack.getFirst().getReadable() == plus + || stack.getFirst().getReadable() == minus) { + if (stack.getFirst().getReadable() == with) { + stack.pollFirst(); + stackTmp.add(stack.pollFirst()); + manageStack(stackTmp); + } else { // working on plus and minus operators + Element ope = stack.pollFirst(); // need the three first elements of the stack to manage + Element duration = stack.pollFirst(); // these operators + Element unit = stack.pollFirst(); + stackTmp.add(ope); + stackTmp.add(duration); + stackTmp.add(unit); + manageStack(stackTmp); + } + } + } + + + /** + * XOR operator construction and writing + * + * @param leftMD left Metadata of the XOR predicate + * @param rightMD right Metadata of the XOR predicate + */ + private void manageXOR(Metadata leftMD, Metadata rightMD) { + write("(!" + leftMD + " && " + rightMD + ") || (" + leftMD + " && !" + rightMD + ")"); + } + + @Override + public void startWhen(WhenMetadata metadata, int depth) { + write("if("); + } + + @Override + public void endWhen(WhenMetadata metadata, int depth) { + while (parenthese_depth > 0) { + write(")"); //closing parenthesis + parenthese_depth--; + } + write("){ true;}else{ false;}\n"); + } + + /** + * replace in a String object the special characters |, ., ^, $, (, ), [, ], -, {, }, ?, *, + and /. + * + * @param reg the String to format for usage as a regular expression + * @return the formatted String + */ + private String formatRegexp(String reg) { + reg = reg.replace("|", "\\|"); + reg = reg.replace(".", "\\."); + reg = reg.replace("^", "\\^"); + reg = reg.replace("$", "\\$"); + reg = reg.replace("(", "\\("); + reg = reg.replace(")", "\\)"); + reg = reg.replace("[", "\\["); + reg = reg.replace("]", "\\]"); + reg = reg.replace("-", "\\-"); + reg = reg.replace("{", "\\{"); + reg = reg.replace("}", "\\}"); + reg = reg.replace("?", "\\?"); + reg = reg.replace("*", "\\*"); + reg = reg.replace("+", "\\+"); + reg = reg.replace("/", "\\/"); + return reg; + } + + protected void write(String str) { + try { + ops.write(str.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + } +} diff --git a/extensions/js/src/main/java/io/doov/js/ast/ScriptEngineFactory.java b/extensions/js/src/main/java/io/doov/js/ast/ScriptEngineFactory.java new file mode 100644 index 000000000..efcdfa836 --- /dev/null +++ b/extensions/js/src/main/java/io/doov/js/ast/ScriptEngineFactory.java @@ -0,0 +1,29 @@ +package io.doov.js.ast; + +import javax.script.ScriptEngine; +import javax.script.ScriptEngineManager; +import javax.script.ScriptException; +import java.io.InputStream; +import java.io.InputStreamReader; + +public class ScriptEngineFactory { + + private static final String MOMENT_JS_SRC = "/META-INF/resources/webjars/momentjs/2.10.3/min/moment.min.js"; + private static final String ENGINE_NAME = "nashorn"; + + public static ScriptEngine create() { + ScriptEngineManager sem = new ScriptEngineManager(); // creation of an engine manager + ScriptEngine engine = sem.getEngineByName(ENGINE_NAME); // engine creation based on nashorn + try { + InputStream stream = ScriptEngineFactory.class.getResourceAsStream(MOMENT_JS_SRC); + InputStreamReader momentJS = new InputStreamReader(stream); + engine.eval(momentJS); // evaluating moment.js + } catch (ScriptException se) { + se.printStackTrace(); + } + return engine; + } + +} + + diff --git a/sample/settings.gradle b/sample/settings.gradle index b7378b9d8..18d54d62a 100644 --- a/sample/settings.gradle +++ b/sample/settings.gradle @@ -4,6 +4,7 @@ includeBuild('../') { dependencySubstitution { substitute module('io.doov:doov-core') with project(':doov-core') substitute module('io.doov:doov-assertions') with project(':doov-assertions') + substitute module('io.doov:doov-js-extension') with project(':doov-js-extension') substitute module('io.doov:doov-gradle-generator') with project(':doov-gradle-generator') } } diff --git a/sample/validation/build.gradle b/sample/validation/build.gradle index c84f9c1a1..36e205089 100644 --- a/sample/validation/build.gradle +++ b/sample/validation/build.gradle @@ -2,6 +2,8 @@ dependencies { compile project(':doov-sample-generated') compile group: 'io.doov', name: 'doov-core' compile group: 'io.doov', name: 'doov-assertions' + compile group: 'io.doov', name: 'doov-js-extension' compile group: 'commons-io', name:'commons-io' + compile group: 'org.webjars', name: 'momentjs', version: '2.10.3' } diff --git a/sample/validation/src/test/java/io/doov/sample/validation/js/ast/JsVisitorTest.java b/sample/validation/src/test/java/io/doov/sample/validation/js/ast/JsVisitorTest.java new file mode 100644 index 000000000..b93a40d3c --- /dev/null +++ b/sample/validation/src/test/java/io/doov/sample/validation/js/ast/JsVisitorTest.java @@ -0,0 +1,38 @@ +package io.doov.sample.validation.js.ast; + +import io.doov.js.ast.AstJavascriptVisitor; +import io.doov.sample.validation.SampleRules; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Locale; + +import static io.doov.core.dsl.impl.DefaultRuleRegistry.REGISTRY_DEFAULT; +import static io.doov.core.dsl.meta.i18n.ResourceBundleProvider.BUNDLE; + +public class JsVisitorTest { + + @BeforeAll + public static void init() { + new SampleRules(); + } + + @Test + public void print_javascript_syntax_tree() { + ByteArrayOutputStream ops = new ByteArrayOutputStream(); + REGISTRY_DEFAULT.stream() + .peek(rule -> { + try { + ops.write("--------------------------------\n".getBytes()); + } catch (IOException e) { + e.printStackTrace(); + } + }) + .forEach(rule -> new AstJavascriptVisitor(ops, BUNDLE, Locale.ENGLISH).browse(rule.metadata(), 0)); + System.out.println(new String(ops.toByteArray(), Charset.forName("UTF-8"))); + } + +} diff --git a/sample/validation/src/test/java/io/doov/sample/validation/js/engine/EngineTest.java b/sample/validation/src/test/java/io/doov/sample/validation/js/engine/EngineTest.java new file mode 100644 index 000000000..f55d1f7f2 --- /dev/null +++ b/sample/validation/src/test/java/io/doov/sample/validation/js/engine/EngineTest.java @@ -0,0 +1,84 @@ +package io.doov.sample.validation.js.engine; + +import io.doov.js.ast.AstJavascriptVisitor; +import io.doov.sample.validation.SampleRules; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import javax.script.ScriptEngine; +import javax.script.ScriptException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import static io.doov.core.dsl.impl.DefaultRuleRegistry.REGISTRY_DEFAULT; +import static io.doov.core.dsl.meta.i18n.ResourceBundleProvider.BUNDLE; +import static io.doov.js.ast.ScriptEngineFactory.create; + +public class EngineTest { + @BeforeAll + public static void init() { + new SampleRules(); + } + + @Test + public void exec_javascript_syntax_tree() { + + String mockValue = "var configuration = { max:{email:{size:24}}, min:{age:18}};\n" + + "\tvar account = {email:\"potato@tomato.fr\", " + + "creation: {date : \"2012-10-10\"}, country:\"FR\", company:\"LESFURETS.COM\"," + + " phone:{number:\"+334567890120\"}, timezone:\"2014-06-01T12:00:00-04:00\"};\n" + + "\tvar user = {id:\"notnull\", birthdate:\"1980\"," + + "first:{name:\"french\"}, last:{name:\"FRIES\"} };\n"; // creation of the mock values + + System.out.println("Evaluation of the rules :"); + System.out.println(" Mock value : "); + System.out.println(" " + mockValue); + + ScriptEngine engine = create(); + try { + engine.eval(mockValue); // evaluating the mock values for testing purpose + } catch (ScriptException e) { + e.printStackTrace(); + } + ByteArrayOutputStream ops = new ByteArrayOutputStream(); + + final int[] index = new int[1]; // index as a tab, usage in lambda expression + final int[] counter = new int[1]; + index[0] = 0; + counter[0] = 0; + REGISTRY_DEFAULT.stream().forEach(rule -> { + ops.reset(); + try { + index[0]++; + System.out.println("--------------------------------\n"); + new AstJavascriptVisitor(ops, BUNDLE, Locale.ENGLISH).browse(rule.metadata(), 0); + String request = new String(ops.toByteArray(), Charset.forName("UTF-8")); + try { + if (index[0] != 14) { // excluding some rules for now (lambda expression) + Object obj = engine.eval(request); // evaluating the current rule to test + ops.write(("\n Rules n°" + index[0]).getBytes(StandardCharsets.UTF_8)); + ops.write(("\n Starting engine checking of : " + rule.readable() + "\n") + .getBytes(StandardCharsets.UTF_8)); + ops.write(("\t\t-" + obj.toString() + "-\n").getBytes(StandardCharsets.UTF_8)); + if (obj.toString().equals("true")) { + counter[0]++; + } + ops.write((" Ending engine checking.\n").getBytes(StandardCharsets.UTF_8)); + } else { + ops.write((" Skipping engine checking because of mapping issue. Rule n°" + index[0] + "\n") + .getBytes(StandardCharsets.UTF_8)); + } + } catch (final ScriptException se) { + throw new RuntimeException(se); + } + System.out.println(new String(ops.toByteArray(), Charset.forName("UTF-8"))); + } catch (IOException e) { + e.printStackTrace(); + } + }); + System.out.println("Passing " + counter[0] + " out of " + index[0] + " tests with true value."); + } +} diff --git a/settings.gradle b/settings.gradle index 9ac55245b..4d3fafaf7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,9 +5,11 @@ include 'doov-assertions' include 'doov-generator-core' include 'doov-gradle-generator' include 'doov-maven-generator' +include 'doov-js-extension' project(':doov-core').projectDir = file('core') project(':doov-assertions').projectDir = file('assertions') project(':doov-generator-core').projectDir = file('generator/generator-core') project(':doov-gradle-generator').projectDir = file('generator/generator-gradle-plugin') project(':doov-maven-generator').projectDir = file('generator/generator-maven-plugin') +project(':doov-js-extension').projectDir = file('extensions/js')