Diffy.java

/*
 * Copyright 2013 Bazaarvoice, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.bazaarvoice.jolt;

import java.util.List;
import java.util.Map;

/**
 * JSON Diff tool that will walk two "JSON" objects simultaneously and identify mismatches.
 *
 * Algorithm :
 *   1) make a copy of both input objects
 *   2) walk both objects and _remove_ items that match
 *   3) return what is left of the two objects in the Result
 *
 * In the case a full / "sucessful" match, Diffy returns a Result object with isEmpty() == true.
 */
public class Diffy {

    private final JsonUtil jsonUtil;

    public Diffy() {
        jsonUtil = JsonUtils.getDefaultJsonUtil();
    }

    /**
     * Pass in a custom jsonUtil to use for the cloneJson method.
     */
    public Diffy( JsonUtil jsonUtil ) {
        this.jsonUtil = jsonUtil;
    }

    public Result diff(Object expected, Object actual) {
        Object expectedCopy = jsonUtil.cloneJson( expected );
        Object actualCopy = jsonUtil.cloneJson( actual );
        return diffHelper( expectedCopy, actualCopy );
    }

    @SuppressWarnings( "unchecked" )
    protected Result diffHelper(Object expected, Object actual) {
        if (expected instanceof Map) {
            if (!(actual instanceof Map)) {
                return new Result( expected, actual );
            }
            return diffMap( (Map<String, Object>) expected, (Map<String, Object>) actual );
        }
        else if (expected instanceof List) {
            if (!(actual instanceof List)) {
                return new Result( expected, actual );
            }
            return diffList( (List<Object>) expected, (List<Object>) actual );
        }
        return this.diffScalar( expected, actual );
    }

    protected Result diffMap(Map<String, Object> expected, Map<String, Object> actual) {

        // Make a copy of the expected keySet so that we can remove things w/out concurrent mod exceptions
        String[] expectedKeys = expected.keySet().toArray( new String[ expected.keySet().size() ] );
        for (String key : expectedKeys ) {
            Result subResult = diffHelper( expected.get( key ), actual.get( key ) );
            if (subResult.isEmpty()) {
                expected.remove( key );
                actual.remove( key );
            }
        }
        if (expected.isEmpty() && actual.isEmpty()) {
            return new Result();
        }
        return new Result( expected, actual );
    }

    protected Result diffList(List<Object> expected, List<Object> actual) {
        int shortlen = Math.min( expected.size(), actual.size() );
        boolean emptyDiff = true;
        for (int i=0; i<shortlen; i++) {
            Result subresult = diffHelper( expected.get( i ), actual.get( i ) );
            expected.set( i, subresult.expected );
            actual.set( i, subresult.actual );
            emptyDiff = emptyDiff && subresult.isEmpty();
        }
        if (emptyDiff && (expected.size() == actual.size())) {
            return new Result();
        }
        return new Result( expected, actual );
    }

    protected Result diffScalar(Object expected, Object actual) {
        if (expected == null) {
            if (actual == null) {
                return new Result();                    // both null, isEmpty diff
            }
            return new Result( null, actual );      // one is null, full diff
        }
        if (actual == null) {
            return new Result( expected, null );      // one is null, full diff
        }
        if (scalarEquals( expected, actual ) ) {
            return new Result();                        // equivalent, isEmpty diff
        }
        return new Result( expected, actual );          // non-equivalent, full diff
    }

    /**
     * Allow subclasses to handle things like Long 0 versus Int 0.  They should be the same,
     *  but the .equals doesn't handle it.
     */
    protected boolean scalarEquals( Object expected, Object actual ) {
        return expected.equals( actual );
    }

    /**
     * Contains the unmatched fields from the Diffy operation.
     *
     * A sucessful/identical match returns isEmpty() == true.
     */
    public static class Result {
        public Object expected;
        public Object actual;
        public Result() {}
        public Result(Object expected, Object actual) {
            this.expected = expected;
            this.actual = actual;
        }
        public boolean isEmpty() {
            return (expected == null) && (actual == null);
        }

        @Override
        public String toString() {
            if(isEmpty()) {
                return "There is no difference!";
            }
            else {
                return "\nExpected:\n" + JsonUtils.toPrettyJsonString(expected) + "\n" +
                       "\nActual\n" + JsonUtils.toPrettyJsonString(actual);
            }
        }
    }
}