diff --git a/karate-core/src/main/java/com/intuit/karate/Match.java b/karate-core/src/main/java/com/intuit/karate/Match.java index 5ae31713f..1f55e7920 100644 --- a/karate-core/src/main/java/com/intuit/karate/Match.java +++ b/karate-core/src/main/java/com/intuit/karate/Match.java @@ -23,6 +23,7 @@ */ package com.intuit.karate; +import com.intuit.karate.MatchOperator.CoreOperator; import com.intuit.karate.graal.JsEngine; import java.lang.reflect.Array; import java.util.ArrayList; @@ -38,31 +39,70 @@ import java.util.regex.Pattern; import org.w3c.dom.Node; +import static com.intuit.karate.Match.MatchOperatorFactory.*; + /** * * @author pthomas3 */ public class Match { + + // Enum constant with that value should never be returned by Match.macroToMatchType. + private static final int TYPE_DOES_NOT_SUPPORT_MACRO_SHORTCUT = -1; + + interface MatchOperatorFactory { + MatchOperator create(boolean matchEachEmptyAllowed); + + static MatchOperatorFactory not(CoreOperatorFactory delegateFactory, String failureMessage) { + return matchEachEmptyAllowed -> new MatchOperator.NotOperator(delegateFactory.create(matchEachEmptyAllowed), failureMessage); + } + + static MatchOperatorFactory deep(CoreOperatorFactory delegateFactory) { + return matchEachEmptyAllowed -> delegateFactory.create(matchEachEmptyAllowed).deep(); + } + + static MatchOperatorFactory each(MatchOperatorFactory delegateFactory) { + return matchEachEmptyAllowed -> new MatchOperator.EachOperator(delegateFactory.create(matchEachEmptyAllowed), matchEachEmptyAllowed); + } + } + + interface CoreOperatorFactory extends MatchOperatorFactory { + @Override + CoreOperator create(boolean matchEachEmptyAllowed); + } + public static enum Type { - EQUALS, - NOT_EQUALS, - CONTAINS, - NOT_CONTAINS, - CONTAINS_ONLY, - CONTAINS_ANY, - CONTAINS_DEEP, - CONTAINS_ONLY_DEEP, - CONTAINS_ANY_DEEP, - EACH_EQUALS, - EACH_NOT_EQUALS, - EACH_CONTAINS, - EACH_NOT_CONTAINS, - EACH_CONTAINS_ONLY, - EACH_CONTAINS_ANY, - EACH_CONTAINS_DEEP + EQUALS(CoreOperator::equalsOperator, 0), + NOT_EQUALS(not(CoreOperator::equalsOperator, "equals"), 2), + CONTAINS(CoreOperator::containsOperator, 1), + NOT_CONTAINS(not(CoreOperator::containsOperator, "actual contains expected"), 2), + CONTAINS_ONLY(CoreOperator::containsOnlyOperator, 2), + CONTAINS_ANY(CoreOperator::containsAnyOperator, 2), + CONTAINS_DEEP(deep(CoreOperator::containsOperator), 2), + CONTAINS_ONLY_DEEP(deep(CoreOperator::containsOnlyOperator), TYPE_DOES_NOT_SUPPORT_MACRO_SHORTCUT), + CONTAINS_ANY_DEEP(deep(CoreOperator::containsAnyOperator), TYPE_DOES_NOT_SUPPORT_MACRO_SHORTCUT), + EACH_EQUALS(each(EQUALS.operatorFactory), 0), + EACH_NOT_EQUALS(each(NOT_EQUALS.operatorFactory), 2), + EACH_CONTAINS(each(CONTAINS.operatorFactory), 1), + EACH_NOT_CONTAINS(each(NOT_CONTAINS.operatorFactory), 2), + EACH_CONTAINS_ONLY(each(CONTAINS_ONLY.operatorFactory), 2), + EACH_CONTAINS_ANY(each(CONTAINS_ANY.operatorFactory), 2), + EACH_CONTAINS_DEEP(each(CONTAINS_DEEP.operatorFactory), 2); + + final MatchOperatorFactory operatorFactory; + final int shortcutLength; + + Type(MatchOperatorFactory operatorFactory, int shortcutLength) { + this.operatorFactory = operatorFactory; + this.shortcutLength = shortcutLength; + } + + MatchOperator operator(boolean matchEachEmptyAllowed) { + return operatorFactory.create(matchEachEmptyAllowed); + } } static final Result PASS = new Result(true, null); @@ -96,7 +136,7 @@ public Result apply(Value v) { } - static final Map VALIDATORS = new HashMap(11); + static final Map VALIDATORS = new HashMap<>(11); static { VALIDATORS.put("array", v -> v.isList() ? PASS : fail("not an array or list")); @@ -138,7 +178,7 @@ public String toString() { } public Map toMap() { - Map map = new HashMap(2); + Map map = new HashMap<>(2); map.put("pass", pass); map.put("message", message); return map; @@ -155,9 +195,8 @@ static class Context { final String path; final String name; final int index; - final boolean matchEachEmptyAllowed; - Context(JsEngine js, MatchOperation root, boolean xml, int depth, String path, String name, int index, boolean matchEachEmptyAllowed) { + Context(JsEngine js, MatchOperation root, boolean xml, int depth, String path, String name, int index) { this.JS = js; this.root = root; this.xml = xml; @@ -165,25 +204,24 @@ static class Context { this.path = path; this.name = name; this.index = index; - this.matchEachEmptyAllowed = matchEachEmptyAllowed; } Context descend(String name) { if (xml) { String childPath = path.endsWith("/@") ? path + name : (depth == 0 ? "" : path) + "/" + name; - return new Context(JS, root, xml, depth + 1, childPath, name, -1, matchEachEmptyAllowed); + return new Context(JS, root, xml, depth + 1, childPath, name, -1); } else { boolean needsQuotes = name.indexOf('-') != -1 || name.indexOf(' ') != -1 || name.indexOf('.') != -1; String childPath = needsQuotes ? path + "['" + name + "']" : path + '.' + name; - return new Context(JS, root, xml, depth + 1, childPath, name, -1, matchEachEmptyAllowed); + return new Context(JS, root, xml, depth + 1, childPath, name, -1); } } Context descend(int index) { if (xml) { - return new Context(JS, root, xml, depth + 1, path + "[" + (index + 1) + "]", name, index, matchEachEmptyAllowed); + return new Context(JS, root, xml, depth + 1, path + "[" + (index + 1) + "]", name, index); } else { - return new Context(JS, root, xml, depth + 1, path + "[" + index + "]", name, index, matchEachEmptyAllowed); + return new Context(JS, root, xml, depth + 1, path + "[" + index + "]", name, index); } } @@ -213,11 +251,11 @@ public static class Value { } Value(Object value, boolean exceptionOnMatchFailure) { - if (value instanceof Set) { - value = new ArrayList((Set) value); + if (value instanceof Set set) { + value = new ArrayList(set); } else if (value != null && value.getClass().isArray()) { int length = Array.getLength(value); - List list = new ArrayList(length); + List list = new ArrayList<>(length); for (int i = 0; i < length; i++) { list.add(Array.get(value, i)); } @@ -338,8 +376,8 @@ Value getSortedLike(Value other) { if (isMap() && other.isMap()) { Map reference = other.getValue(); Map source = getValue(); - Set remainder = new LinkedHashSet(source.keySet()); - Map result = new LinkedHashMap(source.size()); + Set remainder = new LinkedHashSet<>(source.keySet()); + Map result = new LinkedHashMap<>(source.size()); reference.keySet().forEach(key -> { if (source.containsKey(key)) { result.put(key, source.get(key)); @@ -357,15 +395,13 @@ Value getSortedLike(Value other) { @Override public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("[type: ").append(type); - sb.append(", value: ").append(value); - sb.append("]"); - return sb.toString(); + return "[type: " + type + + ", value: " + value + + "]"; } public Result is(Type matchType, Object expected) { - MatchOperation mo = new MatchOperation(matchType, this, new Value(parseIfJsonOrXmlString(expected), exceptionOnMatchFailure), false); + MatchOperation mo = new MatchOperation(matchType.operator(false), this, new Value(parseIfJsonOrXmlString(expected), exceptionOnMatchFailure)); mo.execute(); if (mo.pass) { return Match.PASS; @@ -442,7 +478,7 @@ public Result isEachContainingAny(Object expected) { } public static Result execute(JsEngine js, Type matchType, Object actual, Object expected, boolean matchEachEmptyAllowed) { - MatchOperation mo = new MatchOperation(js, matchType, new Value(actual), new Value(expected), matchEachEmptyAllowed); + MatchOperation mo = new MatchOperation(js, matchType.operator(matchEachEmptyAllowed), new Value(actual), new Value(expected)); mo.execute(); if (mo.pass) { return PASS; @@ -452,8 +488,7 @@ public static Result execute(JsEngine js, Type matchType, Object actual, Object } public static Object parseIfJsonOrXmlString(Object o) { - if (o instanceof String) { - String s = (String) o; + if (o instanceof String s) { if (s.isEmpty()) { return o; } else if (JsonUtils.isJson(s)) { diff --git a/karate-core/src/main/java/com/intuit/karate/MatchOperation.java b/karate-core/src/main/java/com/intuit/karate/MatchOperation.java index 9d972420a..185997cc4 100644 --- a/karate-core/src/main/java/com/intuit/karate/MatchOperation.java +++ b/karate-core/src/main/java/com/intuit/karate/MatchOperation.java @@ -48,42 +48,39 @@ public class MatchOperation { public static final String REGEX = "regex"; final Match.Context context; - final Match.Type type; + final MatchOperator type; final Match.Value actual; final Match.Value expected; final List failures; - // TODO merge this with Match.Type which should be a complex object not an enum - final boolean matchEachEmptyAllowed; boolean pass = true; - private String failReason; + String failReason; - MatchOperation(Match.Type type, Match.Value actual, Match.Value expected, boolean matchEachEmptyAllowed) { - this(JsEngine.global(), null, type, actual, expected, matchEachEmptyAllowed); + MatchOperation(MatchOperator type, Match.Value actual, Match.Value expected) { + this(JsEngine.global(), null, type, actual, expected); } - MatchOperation(JsEngine js, Match.Type type, Match.Value actual, Match.Value expected, boolean matchEachEmptyAllowed) { - this(js, null, type, actual, expected, matchEachEmptyAllowed); + MatchOperation(JsEngine js, MatchOperator type, Match.Value actual, Match.Value expected) { + this(js, null, type, actual, expected); } - MatchOperation(Match.Context context, Match.Type type, Match.Value actual, Match.Value expected, boolean matchEachEmptyAllowed) { - this(null, context, type, actual, expected, matchEachEmptyAllowed); + MatchOperation(Match.Context context, MatchOperator type, Match.Value actual, Match.Value expected) { + this(null, context, type, actual, expected); } - private MatchOperation(JsEngine js, Match.Context context, Match.Type type, Match.Value actual, Match.Value expected, boolean matchEachEmptyAllowed) { + private MatchOperation(JsEngine js, Match.Context context, MatchOperator type, Match.Value actual, Match.Value expected) { this.type = type; this.actual = actual; this.expected = expected; - this.matchEachEmptyAllowed = matchEachEmptyAllowed; if (context == null) { if (js == null) { js = JsEngine.global(); } this.failures = new ArrayList(); if (actual.isXml()) { - this.context = new Match.Context(js, this, true, 0, "/", "", -1, matchEachEmptyAllowed); + this.context = new Match.Context(js, this, true, 0, "/", "", -1); } else { - this.context = new Match.Context(js, this, false, 0, "$", "", -1, matchEachEmptyAllowed); + this.context = new Match.Context(js, this, false, 0, "$", "", -1); } } else { this.context = context; @@ -91,564 +88,16 @@ private MatchOperation(JsEngine js, Match.Context context, Match.Type type, Matc } } - private Match.Type fromMatchEach() { - switch (type) { - case EACH_CONTAINS: - return Match.Type.CONTAINS; - case EACH_NOT_CONTAINS: - return Match.Type.NOT_CONTAINS; - case EACH_CONTAINS_ONLY: - return Match.Type.CONTAINS_ONLY; - case EACH_CONTAINS_ANY: - return Match.Type.CONTAINS_ANY; - case EACH_EQUALS: - return Match.Type.EQUALS; - case EACH_NOT_EQUALS: - return Match.Type.NOT_EQUALS; - case EACH_CONTAINS_DEEP: - return Match.Type.CONTAINS_DEEP; - default: - throw new RuntimeException("unexpected outer match type: " + type); - } - } - - private static Match.Type macroToMatchType(boolean each, String macro) { - if (macro.startsWith("^^")) { - return each ? Match.Type.EACH_CONTAINS_ONLY : Match.Type.CONTAINS_ONLY; - } else if (macro.startsWith("^+")) { - return each ? Match.Type.EACH_CONTAINS_DEEP : Match.Type.CONTAINS_DEEP; - } else if (macro.startsWith("^*")) { - return each ? Match.Type.EACH_CONTAINS_ANY : Match.Type.CONTAINS_ANY; - } else if (macro.startsWith("^")) { - return each ? Match.Type.EACH_CONTAINS : Match.Type.CONTAINS; - } else if (macro.startsWith("!^")) { - return each ? Match.Type.EACH_NOT_CONTAINS : Match.Type.NOT_CONTAINS; - } else if (macro.startsWith("!=")) { - return each ? Match.Type.EACH_NOT_EQUALS : Match.Type.NOT_EQUALS; - } else { - return each ? Match.Type.EACH_EQUALS : Match.Type.EQUALS; - } - } - - private static int matchTypeToStartPos(Match.Type mt) { - switch (mt) { - case CONTAINS_ONLY: - case EACH_CONTAINS_ONLY: - case CONTAINS_DEEP: - case EACH_CONTAINS_DEEP: - case CONTAINS_ANY: - case EACH_CONTAINS_ANY: - case NOT_CONTAINS: - case EACH_NOT_CONTAINS: - case NOT_EQUALS: - case EACH_NOT_EQUALS: - return 2; - case CONTAINS: - case EACH_CONTAINS: - return 1; - default: - return 0; - } - } - boolean execute() { - switch (type) { - case EACH_CONTAINS: - case EACH_NOT_CONTAINS: - case EACH_CONTAINS_ONLY: - case EACH_CONTAINS_ANY: - case EACH_EQUALS: - case EACH_NOT_EQUALS: - case EACH_CONTAINS_DEEP: - if (actual.isList()) { - List list = actual.getValue(); - if (list.isEmpty() && !matchEachEmptyAllowed) { - return fail("match each failed, empty array / list"); - } - Match.Type nestedMatchType = fromMatchEach(); - int count = list.size(); - for (int i = 0; i < count; i++) { - Object o = list.get(i); - context.JS.put("_$", o); - MatchOperation mo = new MatchOperation(context.descend(i), nestedMatchType, new Match.Value(o), expected, matchEachEmptyAllowed); - mo.execute(); - context.JS.bindings.removeMember("_$"); - if (!mo.pass) { - return fail("match each failed at index " + i); - } - } - // if we reached here all / each LHS items completed successfully - return true; - } else { - return fail("actual is not an array or list"); - } - default: - // do nothing - } - if (actual.isNotPresent()) { - if (!expected.isString() || !expected.getAsString().startsWith("#")) { - return fail("actual path does not exist"); - } - } - if (actual.type != expected.type) { - switch (type) { - case CONTAINS: - case NOT_CONTAINS: - case CONTAINS_ANY: - case CONTAINS_ONLY: - case CONTAINS_DEEP: - case CONTAINS_ONLY_DEEP: - case CONTAINS_ANY_DEEP: - // don't tamper with strings on the RHS that represent arrays or objects - if (!expected.isList() && !(expected.isString() && expected.isArrayObjectOrReference())) { - MatchOperation mo = new MatchOperation(context, type, actual, new Match.Value(Collections.singletonList(expected.getValue())), matchEachEmptyAllowed); - mo.execute(); - return mo.pass ? pass() : fail(mo.failReason); - } - break; - default: - // do nothing - } - if (expected.isXml() && actual.isMap()) { - // special case, auto-convert rhs - MatchOperation mo = new MatchOperation(context, type, actual, new Match.Value(XmlUtils.toObject(expected.getValue(), true)), matchEachEmptyAllowed); - mo.execute(); - return mo.pass ? pass() : fail(mo.failReason); - } - if (expected.isString()) { - String expStr = expected.getValue(); - if (!expStr.startsWith("#")) { // edge case if rhs is macro - return type == Match.Type.NOT_EQUALS ? pass() : fail("data types don't match"); - } - } else { - return type == Match.Type.NOT_EQUALS ? pass() : fail("data types don't match"); - } - } - if (expected.isString()) { - String expStr = expected.getValue(); - if (expStr.startsWith("#")) { - switch (type) { - case NOT_EQUALS: - return macroEqualsExpected(expStr) ? fail("is equal") : pass(); - case NOT_CONTAINS: - return macroEqualsExpected(expStr) ? fail("actual contains expected") : pass(); - default: - return macroEqualsExpected(expStr) ? pass() : fail(null); - } - } - } - switch (type) { - case EQUALS: - return actualEqualsExpected() ? pass() : fail("not equal"); - case NOT_EQUALS: - return actualEqualsExpected() ? fail("is equal") : pass(); - case CONTAINS: - case CONTAINS_ANY: - case CONTAINS_ONLY: - case CONTAINS_DEEP: - case CONTAINS_ONLY_DEEP: - case CONTAINS_ANY_DEEP: - return actualContainsExpected() ? pass() : fail("actual does not contain expected"); - case NOT_CONTAINS: - return actualContainsExpected() ? fail("actual contains expected") : pass(); - default: - throw new RuntimeException("unexpected match type: " + type); - } - } - - private boolean macroEqualsExpected(String expStr) { - boolean optional = expStr.startsWith("##"); - if (optional && actual.isNull()) { // exit early - return true; - } - int minLength = optional ? 3 : 2; - if (expStr.length() > minLength) { - String macro = expStr.substring(minLength - 1); - if (macro.startsWith("(") && macro.endsWith(")")) { - macro = macro.substring(1, macro.length() - 1); - Match.Type nestedType = macroToMatchType(false, macro); - int startPos = matchTypeToStartPos(nestedType); - macro = macro.substring(startPos); - if (actual.isList()) { // special case, look for partial maps within list - switch (nestedType) { - case CONTAINS: - nestedType = Match.Type.CONTAINS_DEEP; - break; - case CONTAINS_ONLY: - nestedType = Match.Type.CONTAINS_ONLY_DEEP; - break; - case CONTAINS_ANY: - nestedType = Match.Type.CONTAINS_ANY_DEEP; - break; - } - } - context.JS.put("$", context.root.actual.getValue()); - context.JS.put("_", actual.getValue()); - JsValue jv = context.JS.eval(macro); - context.JS.bindings.removeMember("$"); - context.JS.bindings.removeMember("_"); - MatchOperation mo = new MatchOperation(context, nestedType, actual, new Match.Value(jv.getValue()), matchEachEmptyAllowed); - return mo.execute(); - } else if (macro.startsWith("[")) { - int closeBracketPos = macro.indexOf(']'); - if (closeBracketPos != -1) { // array, match each - if (!actual.isList()) { - return fail("actual is not an array"); - } - if (closeBracketPos > 1) { - String bracketContents = macro.substring(1, closeBracketPos); - List listAct = actual.getValue(); - int listSize = listAct.size(); - context.JS.put("$", context.root.actual.getValue()); - context.JS.put("_", listSize); - String sizeExpr; - if (containsPlaceholderUnderscore(bracketContents)) { // #[_ < 5] - sizeExpr = bracketContents; - } else { // #[5] | #[$.foo] - sizeExpr = bracketContents + " == _"; - } - JsValue jv = context.JS.eval(sizeExpr); - context.JS.bindings.removeMember("$"); - context.JS.bindings.removeMember("_"); - if (!jv.isTrue()) { - return fail("actual array length is " + listSize); - } - } - if (macro.length() > closeBracketPos + 1) { - macro = StringUtils.trimToNull(macro.substring(closeBracketPos + 1)); - if (macro != null) { - if (macro.startsWith("(") && macro.endsWith(")")) { - macro = macro.substring(1, macro.length() - 1); // strip parens - } - if (macro.startsWith("?")) { // #[]? _.length == 3 - macro = "#" + macro; - } - if (macro.startsWith("#")) { - MatchOperation mo = new MatchOperation(context, Match.Type.EACH_EQUALS, actual, new Match.Value(macro), matchEachEmptyAllowed); - mo.execute(); - return mo.pass ? pass() : fail("all array elements matched"); - } else { // schema reference - Match.Type nestedType = macroToMatchType(true, macro); // match each - int startPos = matchTypeToStartPos(nestedType); - macro = macro.substring(startPos); - JsValue jv = context.JS.eval(macro); - MatchOperation mo = new MatchOperation(context, nestedType, actual, new Match.Value(jv.getValue()), matchEachEmptyAllowed); - return mo.execute(); - } - } - } - return true; // expression within square brackets is ok - } - } else { // '#? _ != 0' | '#string' | '#number? _ > 0' - int questionPos = macro.indexOf('?'); - String validatorName = null; - // in case of regex we don't want to remove the '?' - if (questionPos != -1 && !macro.startsWith(REGEX)) { - validatorName = macro.substring(0, questionPos); - if (macro.length() > questionPos + 1) { - macro = StringUtils.trimToEmpty(macro.substring(questionPos + 1)); - } else { - macro = ""; - } - } else { - validatorName = macro; - macro = ""; - } - validatorName = StringUtils.trimToNull(validatorName); - if (validatorName != null) { - Match.Validator validator = null; - if (validatorName.startsWith(REGEX)) { - String regex = validatorName.substring(5).trim(); - validator = new Match.RegexValidator(regex); - } else { - validator = Match.VALIDATORS.get(validatorName); - } - if (validator != null) { - if (optional && (actual.isNotPresent() || actual.isNull())) { - // pass - } else if (!optional && actual.isNotPresent()) { - // if the element is not present the expected result can only be - // the notpresent keyword, ignored or an optional comparison - return expected.isNotPresent() || "#ignore".contentEquals(expected.getAsString()); - } else { - Match.Result mr = validator.apply(actual); - if (!mr.pass) { - return fail(mr.message); - } - } - } else if (!validatorName.startsWith(REGEX)) { // expected is a string that happens to start with "#" - String actualValue = actual.getValue(); - switch (type) { - case CONTAINS: - return actualValue.contains(expStr); - default: - return actualValue.equals(expStr); - } - } - - } - macro = StringUtils.trimToNull(macro); - if (macro != null && questionPos != -1) { - context.JS.put("$", context.root.actual.getValue()); - context.JS.put("_", actual.getValue()); - JsValue jv = context.JS.eval(macro); - context.JS.bindings.removeMember("$"); - context.JS.bindings.removeMember("_"); - if (!jv.isTrue()) { - return fail("evaluated to 'false'"); - } - } - } - } - return true; // all ok - } - - private static final Pattern UNDERSCORE_PATTERN = Pattern.compile("\\W_\\W|\\W_|_\\W"); - - private boolean containsPlaceholderUnderscore(String bracketContents) { - Matcher m1 = UNDERSCORE_PATTERN.matcher(bracketContents); - while (m1.find()) { - return true; - } - return false; - } - - private boolean actualEqualsExpected() { - switch (actual.type) { - case NULL: - return true; // both are null - case BOOLEAN: - boolean actBoolean = actual.getValue(); - boolean expBoolean = expected.getValue(); - return actBoolean == expBoolean; - case NUMBER: - if (actual.getValue() instanceof BigDecimal || expected.getValue() instanceof BigDecimal) { - BigDecimal actBigDecimal = toBigDecimal(actual.getValue()); - BigDecimal expBigDecimal = toBigDecimal(expected.getValue()); - return actBigDecimal.compareTo(expBigDecimal) == 0; - } else { - Number actNumber = actual.getValue(); - Number expNumber = expected.getValue(); - return actNumber.doubleValue() == expNumber.doubleValue(); - } - case STRING: - return actual.getValue().equals(expected.getValue()); - case BYTES: - byte[] actBytes = actual.getValue(); - byte[] expBytes = expected.getValue(); - return Arrays.equals(actBytes, expBytes); - case LIST: - List actList = actual.getValue(); - List expList = expected.getValue(); - int actListCount = actList.size(); - int expListCount = expList.size(); - if (actListCount != expListCount) { - return fail("actual array length is not equal to expected - " + actListCount + ":" + expListCount); - } - for (int i = 0; i < actListCount; i++) { - Match.Value actListValue = new Match.Value(actList.get(i)); - Match.Value expListValue = new Match.Value(expList.get(i)); - MatchOperation mo = new MatchOperation(context.descend(i), Match.Type.EQUALS, actListValue, expListValue, matchEachEmptyAllowed); - mo.execute(); - if (!mo.pass) { - return fail("array match failed at index " + i); - } - } - return true; - case MAP: - Map actMap = actual.getValue(); - Map expMap = expected.getValue(); - return matchMapValues(actMap, expMap); - case XML: - Map actXml = (Map) XmlUtils.toObject(actual.getValue(), true); - Map expXml = (Map) XmlUtils.toObject(expected.getValue(), true); - return matchMapValues(actXml, expXml); - case OTHER: - return actual.getValue().equals(expected.getValue()); - default: - throw new RuntimeException("unexpected type (match equals): " + actual.type); - } - } - - private boolean matchMapValues(Map actMap, Map expMap) { // combined logic for equals and contains - if (actMap.size() > expMap.size() && (type == Match.Type.EQUALS || type == Match.Type.CONTAINS_ONLY || type == Match.Type.CONTAINS_ONLY_DEEP)) { - int sizeDiff = actMap.size() - expMap.size(); - Map diffMap = new LinkedHashMap(actMap); - for (String key : expMap.keySet()) { - diffMap.remove(key); - } - return fail("actual has " + sizeDiff + " more key(s) than expected - " + JsonUtils.toJson(diffMap)); - } - Set unMatchedKeysAct = new LinkedHashSet(actMap.keySet()); - Set unMatchedKeysExp = new LinkedHashSet(expMap.keySet()); - for (Map.Entry expEntry : expMap.entrySet()) { - String key = expEntry.getKey(); - Object childExp = expEntry.getValue(); - if (!actMap.containsKey(key)) { - if (childExp instanceof String) { - String childString = (String) childExp; - if (childString.startsWith("##") || childString.equals("#ignore") || childString.equals("#notpresent")) { - if (type == Match.Type.CONTAINS_ANY || type == Match.Type.CONTAINS_ANY_DEEP) { - return true; // exit early - } - unMatchedKeysExp.remove(key); - if (unMatchedKeysExp.isEmpty()) { - if (type == Match.Type.CONTAINS || type == Match.Type.CONTAINS_DEEP) { - return true; // all expected keys matched - } - } - continue; - } - } - if (type != Match.Type.CONTAINS_ANY && type != Match.Type.CONTAINS_ANY_DEEP) { - return fail("actual does not contain key - '" + key + "'"); - } - } - Match.Value childActValue = new Match.Value(actMap.get(key)); - Match.Type childMatchType; - if (type == Match.Type.CONTAINS_DEEP) { - childMatchType = childActValue.isMapOrListOrXml() ? Match.Type.CONTAINS_DEEP : Match.Type.EQUALS; - } else if (type == Match.Type.CONTAINS_ONLY_DEEP) { - childMatchType = childActValue.isMapOrListOrXml() ? Match.Type.CONTAINS_ONLY_DEEP : Match.Type.EQUALS; - } else { - childMatchType = Match.Type.EQUALS; - } - MatchOperation mo = new MatchOperation(context.descend(key), childMatchType, childActValue, new Match.Value(childExp), matchEachEmptyAllowed); - mo.execute(); - if (mo.pass) { - if (type == Match.Type.CONTAINS_ANY || type == Match.Type.CONTAINS_ANY_DEEP) { - return true; // exit early - } - unMatchedKeysExp.remove(key); - if (unMatchedKeysExp.isEmpty()) { - if (type == Match.Type.CONTAINS || type == Match.Type.CONTAINS_DEEP) { - return true; // all expected keys matched - } - } - unMatchedKeysAct.remove(key); - } else if (type == Match.Type.EQUALS) { - return fail("match failed for name: '" + key + "'"); - } - } - if (type == Match.Type.CONTAINS_ANY || type == Match.Type.CONTAINS_ANY_DEEP) { - return unMatchedKeysExp.isEmpty() ? true : fail("no key-values matched"); - } - if (unMatchedKeysExp.isEmpty()) { - if (type == Match.Type.CONTAINS || type == Match.Type.CONTAINS_DEEP) { - return true; // all expected keys matched, expMap was empty in the first place - } - if (type == Match.Type.NOT_CONTAINS && !expMap.isEmpty()) { - return true; // hack alert: the NOT_CONTAINS will be reversed by the calling routine - } - } - if (!unMatchedKeysExp.isEmpty()) { - return fail("all key-values did not match, expected has un-matched keys - " + unMatchedKeysExp); - } - if (!unMatchedKeysAct.isEmpty()) { - return fail("all key-values did not match, actual has un-matched keys - " + unMatchedKeysAct); - } - return true; - } - - private boolean actualContainsExpected() { - switch (actual.type) { - case STRING: - String actString = actual.getValue(); - String expString = expected.getValue(); - return actString.contains(expString); - case LIST: - List actList = actual.getValue(); - List expList = expected.getValue(); - int actListCount = actList.size(); - int expListCount = expList.size(); - // visited array used to handle duplicates - boolean[] actVisitedList = new boolean[actListCount]; - if (type != Match.Type.CONTAINS_ANY && type != Match.Type.CONTAINS_ANY_DEEP && expListCount > actListCount) { - return fail("actual array length is less than expected - " + actListCount + ":" + expListCount); - } - if ((type == Match.Type.CONTAINS_ONLY || type == Match.Type.CONTAINS_ONLY_DEEP) && expListCount != actListCount) { - return fail("actual array length is not equal to expected - " + actListCount + ":" + expListCount); - } - for (Object exp : expList) { // for each item in the expected list - boolean found = false; - Match.Value expListValue = new Match.Value(exp); - for (int i = 0; i < actListCount; i++) { - Match.Value actListValue = new Match.Value(actList.get(i)); - Match.Type childMatchType; - switch (type) { - case CONTAINS_DEEP: - childMatchType = actListValue.isMapOrListOrXml() ? Match.Type.CONTAINS_DEEP : Match.Type.EQUALS; - break; - case CONTAINS_ONLY_DEEP: - childMatchType = actListValue.isMapOrListOrXml() ? Match.Type.CONTAINS_ONLY_DEEP : Match.Type.EQUALS; - break; - case CONTAINS_ANY_DEEP: - childMatchType = actListValue.isMapOrListOrXml() ? Match.Type.CONTAINS_ANY : Match.Type.EQUALS; - break; - default: - childMatchType = Match.Type.EQUALS; - } - MatchOperation mo = new MatchOperation(context.descend(i), childMatchType, actListValue, expListValue, matchEachEmptyAllowed); - mo.execute(); - if (mo.pass) { - if (type == Match.Type.CONTAINS_ANY || type == Match.Type.CONTAINS_ANY_DEEP) { - return true; // exit early - } - // contains only : If element is found also check its occurrence in actVisitedList - else if(type == Match.Type.CONTAINS_ONLY) { - // if not yet visited - if(!actVisitedList[i]) { - // mark it visited - actVisitedList[i] = true; - found = true; - break; // next item in expected list - } - // else do nothing does not consider it a match - } - else { - found = true; - break; // next item in expected list - } - } - } - if (!found && type != Match.Type.CONTAINS_ANY && type != Match.Type.CONTAINS_ANY_DEEP) { // if we reached here, all items in the actual list were scanned - return fail("actual array does not contain expected item - " + expListValue.getAsString()); - } - } - if (type == Match.Type.CONTAINS_ANY || type == Match.Type.CONTAINS_ANY_DEEP) { - return fail("actual array does not contain any of the expected items"); - } - return true; // if we reached here, all items in the expected list were found - case MAP: - Map actMap = actual.getValue(); - Map expMap = expected.getValue(); - return matchMapValues(actMap, expMap); - case XML: - Map actXml = (Map) XmlUtils.toObject(actual.getValue()); - Map expXml = (Map) XmlUtils.toObject(expected.getValue()); - return matchMapValues(actXml, expXml); - default: - throw new RuntimeException("unexpected type (match contains): " + actual.type); - } - } - - private static BigDecimal toBigDecimal(Object o) { - if (o instanceof BigDecimal) { - return (BigDecimal) o; - } else if (o instanceof Number) { - Number n = (Number) o; - return BigDecimal.valueOf(n.doubleValue()); - } else { - throw new RuntimeException("expected number instead of: " + o); - } + return type.execute(this); } - private boolean pass() { + boolean pass() { pass = true; return true; } - private boolean fail(String reason) { + boolean fail(String reason) { pass = false; if (reason == null) { return false; diff --git a/karate-core/src/main/java/com/intuit/karate/MatchOperator.java b/karate-core/src/main/java/com/intuit/karate/MatchOperator.java new file mode 100644 index 000000000..09986f35c --- /dev/null +++ b/karate-core/src/main/java/com/intuit/karate/MatchOperator.java @@ -0,0 +1,643 @@ +package com.intuit.karate; + +import com.intuit.karate.graal.JsValue; + +import java.math.BigDecimal; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.intuit.karate.MatchOperation.REGEX; + +public interface MatchOperator { + + public boolean execute(MatchOperation operation); + + class EachOperator implements MatchOperator { + private final MatchOperator delegate; + private final boolean matchEachEmptyAllowed; + + EachOperator(MatchOperator delegate, boolean matchEachEmptyAllowed) { + this.delegate = delegate; + this.matchEachEmptyAllowed = matchEachEmptyAllowed; + } + + public String toString() { + return "EACH_"+delegate; + } + + public boolean execute(MatchOperation operation) { + Match.Value actual = operation.actual; + Match.Context context = operation.context; + if (actual.isList()) { + List list = actual.getValue(); + if (list.isEmpty() && !matchEachEmptyAllowed) { + return operation.fail("match each failed, empty array / list"); + } + int count = list.size(); + for (int i = 0; i < count; i++) { + Object o = list.get(i); + context.JS.put("_$", o); + MatchOperation mo = new MatchOperation(context.descend(i), delegate, new Match.Value(o), operation.expected); + mo.execute(); + context.JS.bindings.removeMember("_$"); + if (!mo.pass) { + return operation.fail("match each failed at index " + i); + } + } + // if we reached here all / each LHS items completed successfully + return true; + } else { + return operation.fail("actual is not an array or list"); + } + } + + } + + class NotOperator implements MatchOperator { + private final CoreOperator delegate; + private final String failureMessage; + + NotOperator(CoreOperator delegate, String failureMessage) { + this.delegate = delegate; + this.failureMessage = failureMessage; + } + + public String toString() { + return "NOT_"+delegate; + } + + public boolean execute(MatchOperation operation) { + Match.Value expected = operation.expected; + // Pre 2515 would only apply this hack to CONTAINS and not CONTAINS_DEEP + if (delegate.isContains() && expected.isMap() && expected.>getValue().isEmpty()) { + return true; // hack alert: support for match some_map not contains {} + } + MatchOperation mo = new MatchOperation(operation.context, delegate, operation.actual, expected); + mo.execute(); + if (!mo.pass) { + return operation.pass(); + } + return operation.fail(failureMessage); + } + } + + class CoreOperator implements MatchOperator { + + private final boolean isEquals; + private final boolean isContains; + private final boolean isContainsAny; + private final boolean isContainsOnly; + private final boolean isDeep; + private final boolean matchEachEmptyAllowed; + // NOt strictly required. We could create a new instance in childOperator but keeping it as an instance field + // is a minor optimization. + private final CoreOperator equalsOperator; + + private CoreOperator(boolean isEquals, boolean isContains, boolean isContainsAny, boolean isContainsOnly, boolean matchEachEmptyAllowed) { + this(isEquals, isContains, isContainsAny, isContainsOnly, false, matchEachEmptyAllowed); + } + + private CoreOperator(boolean isEquals, boolean isContains, boolean isContainsAny, boolean isContainsOnly, boolean isDeep, boolean matchEachEmptyAllowed) { + this.isEquals = isEquals; + this.isContains = isContains; + this.isContainsAny = isContainsAny; + this.isContainsOnly = isContainsOnly; + this.isDeep = isDeep; + this.matchEachEmptyAllowed = matchEachEmptyAllowed; + this.equalsOperator = isEquals?this:equalsOperator(matchEachEmptyAllowed); + } + + public boolean execute(MatchOperation operation) { + Match.Value actual = operation.actual; + Match.Value expected = operation.expected; + Match.Context context = operation.context; + if (actual.isNotPresent()) { + if (!expected.isString() || !expected.getAsString().startsWith("#")) { + return operation.fail("actual path does not exist"); + } + } + boolean isContainsFamily = isContainsFamily(); + if (actual.type != expected.type) { + if (isContainsFamily && + // don't tamper with strings on the RHS that represent arrays or objects + (!expected.isList() && !(expected.isString() && expected.isArrayObjectOrReference()))) { + MatchOperation mo = new MatchOperation(context, this, actual, new Match.Value(Collections.singletonList(expected.getValue()))); + mo.execute(); + return mo.pass ? operation.pass() : operation.fail(mo.failReason); + } + if (expected.isXml() && actual.isMap()) { + // special case, auto-convert rhs + MatchOperation mo = new MatchOperation(context, this, actual, new Match.Value(XmlUtils.toObject(expected.getValue(), true))); + mo.execute(); + return mo.pass ? operation.pass() : operation.fail(mo.failReason); + } + if (expected.isString()) { + String expStr = expected.getValue(); + if (!expStr.startsWith("#")) { // edge case if rhs is macro + return operation.fail("data types don't match"); + } + } else { + return operation.fail("data types don't match"); + } + } + if (expected.isString()) { + String expStr = expected.getValue(); + if (expStr.startsWith("#")) { + return macroEqualsExpected(operation, expStr) ? operation.pass() : operation.fail(null); + } + } + if (isEquals()) { + return actualEqualsExpected(operation) ? operation.pass() : operation.fail("not equal"); + } else if (isContainsFamily) { + return actualContainsExpected(operation) ? operation.pass() : operation.fail("actual does not contain expected"); + } + throw new RuntimeException("unexpected match operator: " + this); + } + + + private boolean macroEqualsExpected(MatchOperation operation, String expStr) { + Match.Value actual = operation.actual; + Match.Value expected = operation.expected; + Match.Context context = operation.context; + + boolean optional = expStr.startsWith("##"); + if (optional && actual.isNull()) { // exit early + return true; + } + int minLength = optional ? 3 : 2; + if (expStr.length() > minLength) { + String macro = expStr.substring(minLength - 1); + if (macro.startsWith("(") && macro.endsWith(")")) { + macro = macro.substring(1, macro.length() - 1); + Match.Type nestedType = macroToMatchType(false, macro); + int startPos = matchTypeToStartPos(nestedType); + macro = macro.substring(startPos); + MatchOperator nestedOperator = nestedType.operator(isMatchEachEmptyAllowed()); + if (isContainsFamily() && actual.isList()) { // special case, look for partial maps within list + nestedOperator = macroOperator(nestedOperator); + } + context.JS.put("$", context.root.actual.getValue()); + context.JS.put("_", actual.getValue()); + JsValue jv = context.JS.eval(macro); + context.JS.bindings.removeMember("$"); + context.JS.bindings.removeMember("_"); + MatchOperation mo = new MatchOperation(context, nestedOperator, actual, new Match.Value(jv.getValue())); + return mo.execute(); + } else if (macro.startsWith("[")) { + int closeBracketPos = macro.indexOf(']'); + if (closeBracketPos != -1) { // array, match each + if (!actual.isList()) { + return operation.fail("actual is not an array"); + } + if (closeBracketPos > 1) { + String bracketContents = macro.substring(1, closeBracketPos); + List listAct = actual.getValue(); + int listSize = listAct.size(); + context.JS.put("$", context.root.actual.getValue()); + context.JS.put("_", listSize); + String sizeExpr; + if (containsPlaceholderUnderscore(bracketContents)) { // #[_ < 5] + sizeExpr = bracketContents; + } else { // #[5] | #[$.foo] + sizeExpr = bracketContents + " == _"; + } + JsValue jv = context.JS.eval(sizeExpr); + context.JS.bindings.removeMember("$"); + context.JS.bindings.removeMember("_"); + if (!jv.isTrue()) { + return operation.fail("actual array length is " + listSize); + } + } + if (macro.length() > closeBracketPos + 1) { + macro = StringUtils.trimToNull(macro.substring(closeBracketPos + 1)); + if (macro != null) { + if (macro.startsWith("(") && macro.endsWith(")")) { + macro = macro.substring(1, macro.length() - 1); // strip parens + } + if (macro.startsWith("?")) { // #[]? _.length == 3 + macro = "#" + macro; + } + if (macro.startsWith("#")) { + MatchOperation mo = new MatchOperation(context, Match.Type.EACH_EQUALS.operator(isMatchEachEmptyAllowed()), actual, new Match.Value(macro)); + mo.execute(); + return mo.pass ? operation.pass() : operation.fail("all array elements matched"); + } else { // schema reference + Match.Type nestedType = macroToMatchType(true, macro); // match each + int startPos = matchTypeToStartPos(nestedType); + macro = macro.substring(startPos); + JsValue jv = context.JS.eval(macro); + MatchOperation mo = new MatchOperation(context, nestedType.operator(isMatchEachEmptyAllowed()), actual, new Match.Value(jv.getValue())); + return mo.execute(); + } + } + } + return true; // expression within square brackets is ok + } + } else { // '#? _ != 0' | '#string' | '#number? _ > 0' + int questionPos = macro.indexOf('?'); + String validatorName = null; + // in case of regex we don't want to remove the '?' + if (questionPos != -1 && !macro.startsWith(REGEX)) { + validatorName = macro.substring(0, questionPos); + if (macro.length() > questionPos + 1) { + macro = StringUtils.trimToEmpty(macro.substring(questionPos + 1)); + } else { + macro = ""; + } + } else { + validatorName = macro; + macro = ""; + } + validatorName = StringUtils.trimToNull(validatorName); + if (validatorName != null) { + Match.Validator validator = null; + if (validatorName.startsWith(REGEX)) { + String regex = validatorName.substring(5).trim(); + validator = new Match.RegexValidator(regex); + } else { + validator = Match.VALIDATORS.get(validatorName); + } + if (validator != null) { + if (optional && (actual.isNotPresent() || actual.isNull())) { + // pass + } else if (!optional && actual.isNotPresent()) { + // if the element is not present the expected result can only be + // the notpresent keyword, ignored or an optional comparison + return expected.isNotPresent() || "#ignore".contentEquals(expected.getAsString()); + } else { + Match.Result mr = validator.apply(actual); + if (!mr.pass) { + return operation.fail(mr.message); + } + } + } else if (!validatorName.startsWith(REGEX)) { // expected is a string that happens to start with "#" + String actualValue = actual.getValue(); + // Pre 2515 would only apply this hack to CONTAINS and not CONTAINS_DEEP + return isContains()?actualValue.contains(expStr):actualValue.equals(expStr); + } + + } + macro = StringUtils.trimToNull(macro); + if (macro != null && questionPos != -1) { + context.JS.put("$", context.root.actual.getValue()); + context.JS.put("_", actual.getValue()); + JsValue jv = context.JS.eval(macro); + context.JS.bindings.removeMember("$"); + context.JS.bindings.removeMember("_"); + if (!jv.isTrue()) { + return operation.fail("evaluated to 'false'"); + } + } + } + } + return true; // all ok + } + + private static final Pattern UNDERSCORE_PATTERN = Pattern.compile("\\W_\\W|\\W_|_\\W"); + + private boolean containsPlaceholderUnderscore(String bracketContents) { + Matcher m1 = UNDERSCORE_PATTERN.matcher(bracketContents); + while (m1.find()) { + return true; + } + return false; + } + + + private static Match.Type macroToMatchType(boolean each, String macro) { + if (macro.startsWith("^^")) { + return each ? Match.Type.EACH_CONTAINS_ONLY : Match.Type.CONTAINS_ONLY; + } else if (macro.startsWith("^+")) { + return each ? Match.Type.EACH_CONTAINS_DEEP : Match.Type.CONTAINS_DEEP; + } else if (macro.startsWith("^*")) { + return each ? Match.Type.EACH_CONTAINS_ANY : Match.Type.CONTAINS_ANY; + } else if (macro.startsWith("^")) { + return each ? Match.Type.EACH_CONTAINS : Match.Type.CONTAINS; + } else if (macro.startsWith("!^")) { + return each ? Match.Type.EACH_NOT_CONTAINS : Match.Type.NOT_CONTAINS; + } else if (macro.startsWith("!=")) { + return each ? Match.Type.EACH_NOT_EQUALS : Match.Type.NOT_EQUALS; + } else { + return each ? Match.Type.EACH_EQUALS : Match.Type.EQUALS; + } + } + + private static int matchTypeToStartPos(Match.Type mt) { + return mt.shortcutLength; + } + + private static BigDecimal toBigDecimal(Object o) { + if (o instanceof BigDecimal) { + return (BigDecimal) o; + } else if (o instanceof Number n) { + return BigDecimal.valueOf(n.doubleValue()); + } else { + throw new RuntimeException("expected number instead of: " + o); + } + } + + private boolean actualEqualsExpected(MatchOperation operation) { + Match.Value actual = operation.actual; + Match.Value expected = operation.expected; + Match.Context context = operation.context; + switch (actual.type) { + case NULL: + return true; // both are null + case BOOLEAN: + boolean actBoolean = actual.getValue(); + boolean expBoolean = expected.getValue(); + return actBoolean == expBoolean; + case NUMBER: + if (actual.getValue() instanceof BigDecimal || expected.getValue() instanceof BigDecimal) { + BigDecimal actBigDecimal = toBigDecimal(actual.getValue()); + BigDecimal expBigDecimal = toBigDecimal(expected.getValue()); + return actBigDecimal.compareTo(expBigDecimal) == 0; + } else { + Number actNumber = actual.getValue(); + Number expNumber = expected.getValue(); + return actNumber.doubleValue() == expNumber.doubleValue(); + } + case STRING: + return actual.getValue().equals(expected.getValue()); + case BYTES: + byte[] actBytes = actual.getValue(); + byte[] expBytes = expected.getValue(); + return Arrays.equals(actBytes, expBytes); + case LIST: + List actList = actual.getValue(); + List expList = expected.getValue(); + int actListCount = actList.size(); + int expListCount = expList.size(); + if (actListCount != expListCount) { + return operation.fail("actual array length is not equal to expected - " + actListCount + ":" + expListCount); + } + for (int i = 0; i < actListCount; i++) { + Match.Value actListValue = new Match.Value(actList.get(i)); + Match.Value expListValue = new Match.Value(expList.get(i)); + MatchOperation mo = new MatchOperation(context.descend(i), equalsOperator, actListValue, expListValue); + mo.execute(); + if (!mo.pass) { + return operation.fail("array match failed at index " + i); + } + } + return true; + case MAP: + Map actMap = actual.getValue(); + Map expMap = expected.getValue(); + return matchMapValues(actMap, expMap, operation); + case XML: + Map actXml = (Map) XmlUtils.toObject(actual.getValue(), true); + Map expXml = (Map) XmlUtils.toObject(expected.getValue(), true); + return matchMapValues(actXml, expXml, operation); + case OTHER: + return actual.getValue().equals(expected.getValue()); + default: + throw new RuntimeException("unexpected type (match equals): " + actual.type); + } + } + + private boolean matchMapValues(Map actMap, Map expMap, MatchOperation operation) { // combined logic for equals and contains + if (actMap.size() > expMap.size() && (isEquals() || isContainsOnly())) { + int sizeDiff = actMap.size() - expMap.size(); + Map diffMap = new LinkedHashMap<>(actMap); + for (String key : expMap.keySet()) { + diffMap.remove(key); + } + return operation.fail("actual has " + sizeDiff + " more key(s) than expected - " + JsonUtils.toJson(diffMap)); + } + Set unMatchedKeysAct = new LinkedHashSet<>(actMap.keySet()); + Set unMatchedKeysExp = new LinkedHashSet<>(expMap.keySet()); + for (Map.Entry expEntry : expMap.entrySet()) { + String key = expEntry.getKey(); + Object childExp = expEntry.getValue(); + if (!actMap.containsKey(key)) { + if (childExp instanceof String childString) { + if (childString.startsWith("##") || childString.equals("#ignore") || childString.equals("#notpresent")) { + if (isContainsAny()) { + return true; // exit early + } + unMatchedKeysExp.remove(key); + if (unMatchedKeysExp.isEmpty()) { + if (isContains()) { + return true; // all expected keys matched + } + } + continue; + } + } + if (!isContainsAny()) { + return operation.fail("actual does not contain key - '" + key + "'"); + } + } + Match.Value childActValue = new Match.Value(actMap.get(key)); + MatchOperator childMatchType = childOperator(childActValue); + MatchOperation mo = new MatchOperation(operation.context.descend(key), childMatchType, childActValue, new Match.Value(childExp)); + mo.execute(); + if (mo.pass) { + if (isContainsAny()) { + return true; // exit early + } + unMatchedKeysExp.remove(key); + if (unMatchedKeysExp.isEmpty()) { + if (isContains()) { + return true; // all expected keys matched + } + } + unMatchedKeysAct.remove(key); + } else if (isEquals()) { + return operation.fail("match failed for name: '" + key + "'"); + } + } + if (isContainsAny()) { + return unMatchedKeysExp.isEmpty() ? true : operation.fail("no key-values matched"); + } + if (unMatchedKeysExp.isEmpty()) { + if (isContains()) { + return true; // all expected keys matched, expMap was empty in the first place + } + // Special hack in pre 2515 to support match some_map not contains {} is now handled in execute() directly + } + if (!unMatchedKeysExp.isEmpty()) { + return operation.fail("all key-values did not match, expected has un-matched keys - " + unMatchedKeysExp); + } + if (!unMatchedKeysAct.isEmpty()) { + return operation.fail("all key-values did not match, actual has un-matched keys - " + unMatchedKeysAct); + } + return true; + } + + private boolean actualContainsExpected(MatchOperation operation) { + Match.Value actual = operation.actual; + Match.Value expected = operation.expected; + Match.Context context = operation.context; + switch (actual.type) { + case STRING: + String actString = actual.getValue(); + String expString = expected.getValue(); + return actString.contains(expString); + case LIST: + List actList = actual.getValue(); + List expList = expected.getValue(); + int actListCount = actList.size(); + int expListCount = expList.size(); + // visited array used to handle duplicates + boolean[] actVisitedList = new boolean[actListCount]; + if (!isContainsAny() && expListCount > actListCount) { + return operation.fail("actual array length is less than expected - " + actListCount + ":" + expListCount); + } + if (isContainsOnly() && expListCount != actListCount) { + return operation.fail("actual array length is not equal to expected - " + actListCount + ":" + expListCount); + } + for (Object exp : expList) { // for each item in the expected list + boolean found = false; + Match.Value expListValue = new Match.Value(exp); + for (int i = 0; i < actListCount; i++) { + Match.Value actListValue = new Match.Value(actList.get(i)); + MatchOperator childMatchType = childOperator(actListValue); + MatchOperation mo = new MatchOperation(context.descend(i), childMatchType, actListValue, expListValue); + mo.execute(); + if (mo.pass) { + if (isContainsAny()) { + return true; // exit early + } + // contains only : If element is found also check its occurrence in actVisitedList + // Pre 2515 would only apply this hack to CONTAINS_ONLY and not CONTAINS_ONLY_DEEP + else if(isContainsOnly()) { + // if not yet visited + if(!actVisitedList[i]) { + // mark it visited + actVisitedList[i] = true; + found = true; + break; // next item in expected list + } + // else do nothing does not consider it a match + } + else { + found = true; + break; // next item in expected list + } + } + } + if (!found && !isContainsAny()) { // if we reached here, all items in the actual list were scanned + return operation.fail("actual array does not contain expected item - " + expListValue.getAsString()); + } + } + if (isContainsAny()) { + return operation.fail("actual array does not contain any of the expected items"); + } + return true; // if we reached here, all items in the expected list were found + case MAP: + Map actMap = actual.getValue(); + Map expMap = expected.getValue(); + return matchMapValues(actMap, expMap, operation); + case XML: + Map actXml = (Map) XmlUtils.toObject(actual.getValue()); + Map expXml = (Map) XmlUtils.toObject(expected.getValue()); + return matchMapValues(actXml, expXml, operation); + default: + throw new RuntimeException("unexpected type (match contains): " + actual.type); + } + } + + CoreOperator deep() { + return new CoreOperator(isEquals, isContains, isContainsAny, isContainsOnly, true, matchEachEmptyAllowed); + } + + static CoreOperator equalsOperator(boolean matchEachEmptyAllowed) { + return new CoreOperator(true, false, false, false, matchEachEmptyAllowed); + } + + static CoreOperator containsOperator(boolean matchEachEmptyAllowed) { + return new CoreOperator(false, true, false, false, matchEachEmptyAllowed); + } + + static CoreOperator containsAnyOperator(boolean matchEachEmptyAllowed) { + return new CoreOperator(false, false, true, false, matchEachEmptyAllowed); + } + + static CoreOperator containsOnlyOperator(boolean matchEachEmptyAllowed) { + return new CoreOperator(false, false, false, true, matchEachEmptyAllowed); + } + + boolean isEquals() { + return isEquals; + } + + boolean isContains() { + return isContains; + } + + boolean isContainsAny() { + return isContainsAny; + } + + boolean isContainsOnly() { + return isContainsOnly; + } + + boolean isContainsFamily() { + return isContains() || isContainsOnly() || isContainsAny(); + } + + boolean isMatchEachEmptyAllowed() { + return matchEachEmptyAllowed; + } + + MatchOperator childOperator(Match.Value value) { + return isDeep && value.isMapOrListOrXml()?this:equalsOperator; + } + + /** + * The operator specified by the user (^, ^+, ...) is provided as the {@code specifiedOperator} parameter. + * However, when using one of Contains Family operators, it may require some adjustments. + * + *

Example: + *

{@code
+         * def actual = [{ a: 1, b: 'x' }, { a: 2, b: 'y' }]
+         * def part = { a: 1 }
+         * match actual contains '#(^part)'
+         * }
+ * + * In this example, {@code specifiedOperator} is {@code Contains}. However: + *
    + *
  • The specified operator ({@code ^}) is applied when processing the list.
  • + *
  • Child operators are applied when processing objects within the list.
  • + *
+ * + * According to {@link #childOperator(Match.Value)}, {@code Contains}' child operator is {@code Equals}. + * As a result, the code attempts to match {@code { a: 1, b: 'x' } equals { a: 1 }}, which fails. + * + *

What we actually want is to preserve both {@code Contains} operators: + *

    + *
  • The one from the match instruction.
  • + *
  • The one implied by the macro logic.
  • + *
+ * This method achieves that by creating a custom operator that effectively applies two {@code Contains} operations. + * + *

Note: If a third level of matching is required (e.g., the objects in {@code actual} contain other objects), + * it would fall back to the child operator of the child operator, which is {@code Equals}. + * This differs from the legacy implementation, which would enforce a deep {@code Contains}, + * potentially triggering issue #2515. + * + *

That said, {@code Contains Deep} may still be specified explicitly by the user, + * for example, to handle nested structures like objects within objects within lists. + */ + protected MatchOperator macroOperator(MatchOperator specifiedOperator) { + if (isContainsFamily()) { + return isDeep ? this : new CoreOperator(false, isContains(), isContainsAny(), isContainsOnly(), isMatchEachEmptyAllowed()) { + protected MatchOperator childOperator(Match.Value actual) { + return specifiedOperator; + } + }; + } + return specifiedOperator; + } + + public String toString() { + String operatorString = isEquals?"EQUALS":isContains?"CONTAINS":isContainsAny?"CONTAINS_ANY":"CONTAINS_ONLY"; + return isDeep?operatorString+"_DEEP":operatorString; + } + } + +} diff --git a/karate-core/src/test/java/com/intuit/karate/MatchTest.java b/karate-core/src/test/java/com/intuit/karate/MatchTest.java index 3d9f7f4c4..29c1e7e83 100644 --- a/karate-core/src/test/java/com/intuit/karate/MatchTest.java +++ b/karate-core/src/test/java/com/intuit/karate/MatchTest.java @@ -1,15 +1,19 @@ package com.intuit.karate; import static com.intuit.karate.Match.Type.*; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import com.intuit.karate.Match; import com.intuit.karate.Match.Type; import com.intuit.karate.graal.JsEngine; import java.math.BigDecimal; +import java.util.Collections; import java.util.HashSet; import java.util.Map; import java.util.Set; + +import com.intuit.karate.MatchOperation; +import net.minidev.json.JSONArray; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -37,7 +41,7 @@ private void match(Object actual, Match.Type mt, Object expected) { String message; private void message(String expected) { - assertTrue(message != null && message.contains(expected), message); + assertEquals(expected, message); } private void log() { @@ -67,7 +71,12 @@ void testApi() { void testNull() { match(null, EQUALS, null); match("", EQUALS, null, FAILS); - message("data types don't match"); + message(""" + match failed: EQUALS + $ | data types don't match (STRING:NULL) + '' + null + """); match("", NOT_EQUALS, null); match(null, NOT_EQUALS, null, FAILS); } @@ -161,6 +170,7 @@ void testList() { match("[4, 4]", CONTAINS_ONLY, "[4, 4]"); match("[1, 2, 2]", CONTAINS_ONLY, "[2, 2, 1]"); match("[1, 2, 3]", CONTAINS_ONLY, "[2, 2, 3]", FAILS); + match("[2, 3, 2]", CONTAINS_ONLY, "[2, 2, 3]"); match("[2, 2, 3]", CONTAINS_ONLY, "[1, 2, 3]", FAILS); match("[1, 4, 7]", CONTAINS_ONLY, "[4, 7]", FAILS); match("[1, 2, 3]", CONTAINS, "[1, 2, 4]", FAILS); @@ -190,9 +200,35 @@ void testContainsOnlyDeep() { @ValueSource(strings = {"CONTAINS", "CONTAINS_DEEP"}) void testListContains(String containsType) { match("['foo', 'bar']", containsType, "baz", FAILS); - message("actual array does not contain expected item - baz"); + message(""" + match failed: %s + $ | actual does not contain expected | actual array does not contain expected item - baz (LIST:STRING) + ["foo","bar"] + 'baz' + + $[1] | not equal (STRING:STRING) + 'bar' + 'baz' + + $[0] | not equal (STRING:STRING) + 'foo' + 'baz' + """.formatted(containsType)); match("['foo', 'bar']", containsType, "['baz']", FAILS); - message("actual array does not contain expected item - baz"); + message(""" + match failed: %s + $ | actual does not contain expected | actual array does not contain expected item - baz (LIST:LIST) + ["foo","bar"] + ["baz"] + + $[1] | not equal (STRING:STRING) + 'bar' + 'baz' + + $[0] | not equal (STRING:STRING) + 'foo' + 'baz' + """.formatted(containsType)); } @Test @@ -207,19 +243,77 @@ void testListContainsRegex() { match("{ array: ['foo', 'bar'] }", CONTAINS_DEEP, "{ array: '#array' }"); match("{ array: ['foo', 'bar'] }", CONTAINS_ANY, "{ array: '#[] #regex .{3}' }"); match("{ array: ['foo', 'bar'] }", CONTAINS_ANY_DEEP, "{ array: '#[] #regex .{3}' }"); + + match("['foo', 'barr']", CONTAINS, "#regex .{4}"); + match("['foo', 'barr']", CONTAINS_ANY, "#regex .{4}"); + + match("['foo', 'bar']", CONTAINS, "#regex .{4}", FAILS); + message(""" + match failed: CONTAINS + $ | actual does not contain expected | actual array does not contain expected item - #regex .{4} (LIST:STRING) + ["foo","bar"] + '#regex .{4}' + + $[1] | regex match failed (STRING:STRING) + 'bar' + '#regex .{4}' + + $[0] | regex match failed (STRING:STRING) + 'foo' + '#regex .{4}' + """); + } @Test void testListNotContains() { match("['foo', 'bar']", NOT_CONTAINS, "baz"); match("['foo', 'bar']", NOT_CONTAINS, "bar", FAILS); - message("actual contains expected"); + message(""" + match failed: NOT_CONTAINS + $ | actual contains expected (LIST:STRING) + ["foo","bar"] + 'bar' + + $[0] | not equal (STRING:STRING) + 'foo' + 'bar' + """); match( "[{ foo: 1 }, { foo: 2 }, { foo: 3 }]", CONTAINS, "[{ foo: 0 }, { foo: 2 }, { foo: 3 }]", FAILS); - message("$[0] | not equal"); // TODO improve error message for this case + message(""" + match failed: CONTAINS + $ | actual does not contain expected | actual array does not contain expected item - {"foo":0} (LIST:LIST) + [{"foo":1},{"foo":2},{"foo":3}] + [{"foo":0},{"foo":2},{"foo":3}] + + $[2] | not equal | match failed for name: 'foo' (MAP:MAP) + {"foo":3} + {"foo":0} + + $[2].foo | not equal (NUMBER:NUMBER) + 3 + 0 + + $[1] | not equal | match failed for name: 'foo' (MAP:MAP) + {"foo":2} + {"foo":0} + + $[1].foo | not equal (NUMBER:NUMBER) + 2 + 0 + + $[0] | not equal | match failed for name: 'foo' (MAP:MAP) + {"foo":1} + {"foo":0} + + $[0].foo | not equal (NUMBER:NUMBER) + 1 + 0 + """); // TODO improve error message for this case } @Test @@ -227,17 +321,42 @@ void testEach() { match("[1, 2, 3]", EACH_EQUALS, "#number"); match("[1, 2, 3]", EACH_EQUALS, "#number? _ > 0"); match("[1, 2, 3]", EACH_EQUALS, "#number? _ < 2", FAILS); - message("match each failed at index 1"); + String expected = """ + match failed: EACH_EQUALS + $ | match each failed at index 1 (LIST:STRING) + [1,2,3] + '#number? _ < 2' + + $[1] | evaluated to 'false' (NUMBER:STRING) + 2 + '#number? _ < 2' + """; + message(expected); match("[1, 'a', 3]", EACH_EQUALS, "#number", FAILS); - message("$[1] | not a number"); + expected = """ + match failed: EACH_EQUALS + $ | match each failed at index 1 (LIST:STRING) + [1,"a",3] + '#number' + + $[1] | not a number (STRING:STRING) + 'a' + '#number' + """; + message(expected); match("[{ a: 1 }, { a: 2 }]", EACH_EQUALS, "#object"); match("[{ a: 1 }, { a: 2 }]", EACH_EQUALS, "{ a: '#number' }"); } - + @Test void testEachEmpty() { match("[]", EACH_EQUALS, "#number", FAILS); - message("match each failed, empty array / list"); + message(""" + match failed: EACH_EQUALS + $ | match each failed, empty array / list (LIST:STRING) + [] + '#number' + """); } @Test @@ -255,11 +374,33 @@ void testArray(String matchType) { match("[{ a: 1 }, { a: 2 }]", matchType, "#[] #object"); } + @Test + void testIssue2515() { + + Json cat = Json.of( + """ + { + name: 'Billie', + kittens: [ + { id: 23, name: 'Bob', bla: [{ b: '1'}] }, + { id: 42, name: 'Wild' } + ] + } + """); + Json expectedKittens1 = Json.of("[{ id: 42, name: 'Wild' }, { id: 23, name: 'Bob', bla: [{ b: '1'}]}]"); + JsEngine.global().put("expectedKittens1", expectedKittens1.asList()); + match(cat.asMap(), EQUALS, Json.of("{ name: 'Billie', kittens: '#(^^expectedKittens1)' }").asMap()); + + Json expectedKittens2 = Json.of("[{ id: 42, name: 'Wild' }, { id: 23, name: 'Bob', bla: { b: '1'}}]"); + JsEngine.global().put("expectedKittens2", expectedKittens2.asList()); + match(cat.asMap(), EQUALS, Json.of("{ name: 'Billie', kittens: '#(^^expectedKittens2)' }").asMap(), true); + } + @ParameterizedTest @CsvSource(value = { - "EQUALS;EACH_EQUALS", - "CONTAINS;EACH_CONTAINS", - "CONTAINS_DEEP;EACH_CONTAINS_DEEP"}, delimiter = ';') + "EQUALS;EACH_EQUALS", + "CONTAINS;EACH_CONTAINS", + "CONTAINS_DEEP;EACH_CONTAINS_DEEP"}, delimiter = ';') void testSchema(String matchType, String matchEachType) { Json json = Json.of("{ a: '#number' }"); Map map = json.asMap(); @@ -277,8 +418,10 @@ void testSchemaOptionalObject(String matchType) { match("{ foo: null }", matchType, "{ foo: '##(bar)' }"); } + @Test void testMap() { + match("{ a: 1, b: 2, c: 3 }", CONTAINS, "{}"); match("{ a: 1, b: 2, c: 3 }", EQUALS, "{ b: 2, c: 3, a: 1 }"); match("{ a: 1, b: 2, c: 3 }", CONTAINS, "{ b: 2, c: 3, a: 1 }"); match("{ a: 1, b: 2, c: 3 }", CONTAINS_ONLY, "{ b: 2, c: 3, a: 1 }"); @@ -290,34 +433,110 @@ void testMap() { match("{ a: 1, b: 2, c: 3 }", CONTAINS_DEEP, "{ }"); match("{ a: 1, b: 2, c: 3 }", CONTAINS_ANY, "{ z: 9, b: 2 }"); match("{ a: 1, b: 2, c: 3 }", CONTAINS, "{ z: 9, x: 2 }", FAILS); - message("$ | actual does not contain expected | actual does not contain key - 'z'"); + + message(""" + match failed: CONTAINS + $ | actual does not contain expected | actual does not contain key - 'z' (MAP:MAP) + {"a":1,"b":2,"c":3} + {"z":9,"x":2} + + """); match("{ a: 1, b: 2, c: 3 }", CONTAINS_ANY, "{ z: 9, x: 2 }", FAILS); - message("$ | actual does not contain expected | no key-values matched"); - message("$.x | data types don't match"); - message("$.z | data types don't match"); + message(""" + match failed: CONTAINS_ANY + $ | actual does not contain expected | no key-values matched (MAP:MAP) + {"a":1,"b":2,"c":3} + {"z":9,"x":2} + + $.x | data types don't match (NULL:NUMBER) + null + 2 + + $.z | data types don't match (NULL:NUMBER) + null + 9 + """); match("{ a: 1, b: 2, c: 3 }", NOT_CONTAINS, "{ a: 1 }", FAILS); - message("$ | actual contains expected"); + message(""" + match failed: NOT_CONTAINS + $ | actual contains expected (MAP:MAP) + {"a":1,"b":2,"c":3} + {"a":1} + """); match("{ a: 1, b: 2, c: 3 }", NOT_CONTAINS, "{}"); + } @Test void testJsonFailureMessages() { match("{ a: 1, b: 2, c: 3 }", EQUALS, "{ a: 1, b: 9, c: 3 }", FAILS); - message("$.b | not equal"); + String expected = """ + match failed: EQUALS + $ | not equal | match failed for name: 'b' (MAP:MAP) + {"a":1,"b":2,"c":3} + {"a":1,"b":9,"c":3} + + $.b | not equal (NUMBER:NUMBER) + 2 + 9 + """; + message(expected); match("{ a: { b: { c: 1 } } }", EQUALS, "{ a: { b: { c: 2 } } }", FAILS); - message("$.a.b.c | not equal"); + expected = """ + match failed: EQUALS + $ | not equal | match failed for name: 'a' (MAP:MAP) + {"a":{"b":{"c":1}}} + {"a":{"b":{"c":2}}} + + $.a | not equal | match failed for name: 'b' (MAP:MAP) + {"b":{"c":1}} + {"b":{"c":2}} + + $.a.b | not equal | match failed for name: 'c' (MAP:MAP) + {"c":1} + {"c":2} + + $.a.b.c | not equal (NUMBER:NUMBER) + 1 + 2 + """; + message(expected); } @Test void testXmlFailureMessages() { match("1", EQUALS, "2", FAILS); - message("/ | not equal | match failed for name: 'a'"); - message("/a | not equal | match failed for name: 'b'"); - message("/a/b | not equal | match failed for name: 'c'"); - message("/a/b/c | not equal"); + String expected = """ + match failed: EQUALS + / | not equal | match failed for name: 'a' (XML:XML) + 1 + 2 + + /a | not equal | match failed for name: 'b' (MAP:MAP) + 1 + 2 + + /a/b | not equal | match failed for name: 'c' (MAP:MAP) + 1 + 2 + + /a/b/c | not equal (STRING:STRING) + 1 + 2 + """; + message(expected); match("world", EQUALS, "world", FAILS); - message("/ | not equal | match failed for name: 'hello'"); - message("/hello/@foo | not equal"); + expected = """ + match failed: EQUALS + / | not equal | match failed for name: 'hello' (XML:XML) + world + world + + /hello/@foo | not equal (STRING:STRING) + bar + baz + """; + message(expected); } @Test @@ -336,24 +555,74 @@ void testMapFuzzy() { match("{ a: 1, b: 2, c: 3 }", EQUALS, "{ b: 2, c: '#present', a: 1 }"); match("{ a: 1, b: 2, c: 3 }", EQUALS, "{ b: 2, c: '#notnull', a: 1 }"); match("{ a: 1, b: 2, c: 3 }", EQUALS, "{ b: 2, c: '#null', a: 1 }", FAILS); - message("$.c | not null"); + String expected = """ + match failed: EQUALS + $ | not equal | match failed for name: 'c' (MAP:MAP) + {"a":1,"b":2,"c":3} + {"a":1,"b":2,"c":"#null"} + + $.c | not null (NUMBER:STRING) + 3 + '#null' + """; + message(expected); match("{ a: 1, b: 2, c: 3 }", EQUALS, "{ b: 2, c: '#string', a: 1 }", FAILS); - message("$.c | not a string"); + expected = """ + match failed: EQUALS + $ | not equal | match failed for name: 'c' (MAP:MAP) + {"a":1,"b":2,"c":3} + {"a":1,"b":2,"c":"#string"} + + $.c | not a string (NUMBER:STRING) + 3 + '#string' + """; + message(expected); match("{ a: 1, b: 2, c: 3 }", EQUALS, "{ b: 2, c: '#notpresent', a: 1 }", FAILS); - message("$.c | present"); + expected = """ + match failed: EQUALS + $ | not equal | match failed for name: 'c' (MAP:MAP) + {"a":1,"b":2,"c":3} + {"a":1,"b":2,"c":"#notpresent"} + + $.c | present (NUMBER:STRING) + 3 + '#notpresent' + """; + message(expected); match("{ a: 1, b: 'foo', c: 2 }", EQUALS, "{ b: '#regex foo', c: 2, a: 1 }"); match("{ a: 1, b: 'foo', c: 2 }", EQUALS, "{ b: '#regex .+', c: 2, a: 1 }"); match("{ a: 1, b: 'foo', c: 2 }", EQUALS, "{ b: '#regex .{3}', c: 2, a: 1 }"); match("{ a: 1, b: 'foo', c: 2 }", EQUALS, "{ b: '#regex .{2}', c: 2, a: 1 }", FAILS); - message("$.b | regex match failed"); + expected = """ + match failed: EQUALS + $ | not equal | match failed for name: 'b' (MAP:MAP) + {"a":1,"b":"foo","c":2} + {"a":1,"b":"#regex .{2}","c":2} + + $.b | regex match failed (STRING:STRING) + 'foo' + '#regex .{2}' + """; + message(expected); } @Test void testNotPresentOnLhs() { match("#notpresent", EQUALS, 2, FAILS); - message("actual path does not exist"); + message(""" + match failed: EQUALS + $ | actual path does not exist (STRING:NUMBER) + '#notpresent' + 2 + """); match("#notpresent", EQUALS, "foo", FAILS); - message("actual path does not exist"); + message(""" + match failed: EQUALS + $ | actual path does not exist (STRING:STRING) + '#notpresent' + 'foo' + """); } @Test @@ -430,7 +699,6 @@ void testOptionalNotEquals() { @ParameterizedTest @ValueSource(strings = {"EQUALS", "CONTAINS", "CONTAINS_DEEP"}) void testOptional(String matchType) { - match("{ number: '1234' }", matchType, "{ number: '##regex \\\\d+' }"); match("{ }", matchType, "{ number: '##regex \\\\d+' }"); match("{ 'foo': 'bar' }", matchType, "{ foo: '#string', number: '##regex \\\\d+' }"); @@ -470,6 +738,8 @@ void testOptional(String matchType) { "{ 'foo': 'test', 'bar' : [ { 'bar': 'bar' } ] }", matchType, "{ 'foo': '#string', 'bar': '##array' }"); + + match("{ 'foo': null }", matchType, "{ 'foo': '##array' }"); match("{ a: null}", matchType, " { a: '##notnull' }");