You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cayenne.apache.org by nt...@apache.org on 2021/02/02 14:15:43 UTC

[cayenne] branch master updated: Utils for the JSON values comparison

This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git


The following commit(s) were added to refs/heads/master by this push:
     new 376beca  Utils for the JSON values comparison
     new 7d8cfc9  Merge pull request #443 from stariy95/4.2-JSON-comparison
376beca is described below

commit 376beca6e8bb69fa41f5259d6624e94a4c55664f
Author: Nikita Timofeev <st...@gmail.com>
AuthorDate: Wed Dec 2 17:55:34 2020 +0300

    Utils for the JSON values comparison
---
 .../java/org/apache/cayenne/value/GeoJson.java     |   5 +-
 .../main/java/org/apache/cayenne/value/Json.java   |   4 +-
 .../cayenne/value/json/AbstractJsonConsumer.java   | 132 +++++
 .../apache/cayenne/value/json/JsonFormatter.java   |  96 +++
 .../org/apache/cayenne/value/json/JsonReader.java  | 128 ++++
 .../apache/cayenne/value/json/JsonTokenizer.java   | 575 ++++++++++++++++++
 .../org/apache/cayenne/value/json/JsonUtils.java   |  62 ++
 .../MalformedJsonException.java}                   |  36 +-
 .../apache/cayenne/value/json/JsonReaderTest.java  | 427 +++++++++++++
 .../cayenne/value/json/JsonTokenizerTest.java      | 657 +++++++++++++++++++++
 .../apache/cayenne/value/json/JsonUtilsTest.java   |  74 +++
 11 files changed, 2162 insertions(+), 34 deletions(-)

diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/GeoJson.java b/cayenne-server/src/main/java/org/apache/cayenne/value/GeoJson.java
index 7d6589c..b37ea95 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/value/GeoJson.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/GeoJson.java
@@ -21,6 +21,8 @@ package org.apache.cayenne.value;
 
 import java.util.Objects;
 
+import org.apache.cayenne.value.json.JsonUtils;
+
 /**
  * A Cayenne-supported values object that holds GeoJson string.
  *
@@ -44,8 +46,7 @@ public class GeoJson {
         if (o == null || getClass() != o.getClass()) return false;
         GeoJson other = (GeoJson) o;
 
-        // TODO: and ideal comparison would ignore spaces around JSON tokens, but that may be too expensive
-        return Objects.equals(geometry, other.geometry);
+        return JsonUtils.compare(geometry, other.geometry);
     }
 
     @Override
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/Json.java b/cayenne-server/src/main/java/org/apache/cayenne/value/Json.java
index 9252667..d824b41 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/value/Json.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/Json.java
@@ -21,6 +21,8 @@ package org.apache.cayenne.value;
 
 import java.util.Objects;
 
+import org.apache.cayenne.value.json.JsonUtils;
+
 /**
  * A Cayenne-supported values object that holds Json string.
  *
@@ -43,7 +45,7 @@ public class Json {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         Json other = (Json) o;
-        return Objects.equals(json, other.json);
+        return JsonUtils.compare(json, other.json);
     }
 
     @Override
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/json/AbstractJsonConsumer.java b/cayenne-server/src/main/java/org/apache/cayenne/value/json/AbstractJsonConsumer.java
new file mode 100644
index 0000000..d364868
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/json/AbstractJsonConsumer.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+/**
+ * @since 4.2
+ */
+abstract class AbstractJsonConsumer<T> {
+
+    private final JsonTokenizer tokenizer;
+    private State[] states = new State[4];
+    private int currentState = 0;
+
+    AbstractJsonConsumer(String json) {
+        states[currentState] = State.NONE;
+        tokenizer = new JsonTokenizer(json);
+    }
+
+    protected abstract void onArrayStart();
+
+    protected abstract void onArrayEnd();
+
+    protected abstract void onObjectStart();
+
+    protected abstract void onObjectEnd();
+
+    protected abstract void onArrayValue(JsonTokenizer.JsonToken token);
+
+    protected abstract void onObjectKey(JsonTokenizer.JsonToken token);
+
+    protected abstract void onObjectValue(JsonTokenizer.JsonToken token);
+
+    protected abstract void onValue(JsonTokenizer.JsonToken token);
+
+    protected abstract T output();
+
+    T process() {
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            switch (token.getType()) {
+                case ARRAY_START:
+                    onArrayStart();
+                    pushState(State.ARRAY);
+                    break;
+
+                case OBJECT_START:
+                    onObjectStart();
+                    pushState(State.OBJECT_KEY);
+                    break;
+
+                case ARRAY_END:
+                    popState();
+                    onArrayEnd();
+                    break;
+
+                case OBJECT_END:
+                    popState();
+                    onObjectEnd();
+                    break;
+
+                default:
+                    processValue(token);
+                    break;
+            }
+        }
+        return output();
+    }
+
+    private void processValue(JsonTokenizer.JsonToken token) {
+        switch (states[currentState]) {
+            case OBJECT_KEY:
+                setState(State.OBJECT_VALUE);
+                onObjectKey(token);
+                break;
+            case OBJECT_VALUE:
+                setState(State.OBJECT_KEY);
+                onObjectValue(token);
+                break;
+            case ARRAY:
+                onArrayValue(token);
+                break;
+            default:
+                onValue(token);
+        }
+    }
+
+    protected void pushState(State state) {
+        currentState++;
+        if(currentState >= states.length) {
+            State[] newStates = new State[states.length << 2];
+            System.arraycopy(states, 0, newStates, 0, states.length);
+            states = newStates;
+        }
+        states[currentState] = state;
+    }
+
+    protected void setState(State state) {
+        states[currentState] = state;
+    }
+
+    protected void popState() {
+        states[currentState--] = null;
+    }
+
+    protected State currentState() {
+        return states[currentState];
+    }
+
+    protected enum State {
+        NONE,
+        ARRAY,
+        OBJECT_KEY,
+        OBJECT_VALUE
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonFormatter.java b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonFormatter.java
new file mode 100644
index 0000000..3d697a1
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonFormatter.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+/**
+ * @since 4.2
+ */
+class JsonFormatter extends AbstractJsonConsumer<String> {
+
+    private final StringBuilder builder = new StringBuilder();
+
+    JsonFormatter(String json) {
+        super(json);
+    }
+
+    @Override
+    protected void onArrayStart() {
+        builder.append('[');
+    }
+
+    @Override
+    protected void onArrayEnd() {
+        if(builder.charAt(builder.length() - 1) == ' ') {
+            builder.delete(builder.length() - 2, builder.length());
+        }
+        builder.append(']');
+    }
+
+    @Override
+    protected void onObjectStart() {
+        builder.append('{');
+    }
+
+    @Override
+    protected void onObjectEnd() {
+        if(builder.charAt(builder.length() - 1) == ' ') {
+            builder.delete(builder.length() - 2, builder.length());
+        }
+        builder.append('}');
+    }
+
+    @Override
+    protected void onArrayValue(JsonTokenizer.JsonToken token) {
+        appendToken(token);
+        builder.append(", ");
+    }
+
+    @Override
+    protected void onObjectKey(JsonTokenizer.JsonToken token) {
+        appendToken(token);
+        builder.append(": ");
+    }
+
+    @Override
+    protected void onObjectValue(JsonTokenizer.JsonToken token) {
+        appendToken(token);
+        builder.append(", ");
+    }
+
+    @Override
+    protected void onValue(JsonTokenizer.JsonToken token) {
+        appendToken(token);
+    }
+
+    @Override
+    protected String output() {
+        return builder.toString();
+    }
+
+    private void appendToken(JsonTokenizer.JsonToken token) {
+        if(token.type == JsonTokenizer.TokenType.STRING) {
+            builder.append('"');
+        }
+        builder.append(token.getData(), token.from, token.to - token.from);
+        if(token.type == JsonTokenizer.TokenType.STRING) {
+            builder.append('"');
+        }
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonReader.java b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonReader.java
new file mode 100644
index 0000000..7462d5a
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonReader.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @since 4.2
+ */
+class JsonReader extends AbstractJsonConsumer<Object> {
+
+    private final Deque<Object> objects = new ArrayDeque<>(4);
+    private final Deque<Object> names = new ArrayDeque<>(4);
+
+    JsonReader(String json) {
+        super(json);
+    }
+
+    @Override
+    protected void onArrayStart() {
+        objects.addLast(new ArrayList<>(4));
+    }
+
+    @Override
+    protected void onObjectStart() {
+        objects.addLast(new HashMap<>());
+    }
+
+    @Override
+    protected void onArrayValue(JsonTokenizer.JsonToken token) {
+        onArrayValue((Object)token);
+    }
+
+    @SuppressWarnings("unchecked")
+    void onArrayValue(Object value) {
+        Object array = objects.getLast();
+        if(array instanceof List) {
+            ((List<Object>) array).add(value);
+        } else {
+            throw new IllegalStateException("Expected List got " + array.getClass().getSimpleName());
+        }
+    }
+
+    @Override
+    protected void onObjectKey(JsonTokenizer.JsonToken token) {
+        names.addLast(token);
+    }
+
+    @Override
+    protected void onObjectValue(JsonTokenizer.JsonToken token) {
+        onObjectValue(token, false);
+    }
+
+    @SuppressWarnings("unchecked")
+    protected void onObjectValue(Object value, boolean updateState) {
+        Object name = names.pollLast();
+        Object map = objects.getLast();
+        if(map instanceof Map) {
+            ((Map<Object, Object>) map).put(name, value);
+        } else {
+            throw new IllegalStateException("Expected Map got " + map.getClass().getSimpleName());
+        }
+        if(updateState) {
+            setState(State.OBJECT_KEY);
+        }
+    }
+
+    @Override
+    protected void onValue(JsonTokenizer.JsonToken token) {
+        objects.addLast(token);
+    }
+
+    @Override
+    protected void onArrayEnd() {
+        Object value = objects.pollLast();
+        onValue(value);
+    }
+
+    @Override
+    protected void onObjectEnd() {
+        Object value = objects.pollLast();
+        onValue(value);
+    }
+
+    void onValue(Object value) {
+        switch (currentState()) {
+            case OBJECT_VALUE:
+                onObjectValue(value, true);
+                break;
+            case ARRAY:
+                onArrayValue(value);
+                break;
+            default:
+                objects.addLast(value);
+        }
+    }
+
+    @Override
+    protected Object output() {
+        if(objects.isEmpty()) {
+            return null;
+        }
+        return objects.getFirst();
+    }
+
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonTokenizer.java b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonTokenizer.java
new file mode 100644
index 0000000..14f4152
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonTokenizer.java
@@ -0,0 +1,575 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+/**
+ * @since 4.2
+ */
+final class JsonTokenizer {
+
+    private static final char[] NULL_LOWER  = {'n', 'u', 'l', 'l'};
+    private static final char[] NULL_UPPER  = {'N', 'U', 'L', 'L'};
+    private static final char[] TRUE_LOWER  = {'t', 'r', 'u', 'e'};
+    private static final char[] TRUE_UPPER  = {'T', 'R', 'U', 'E'};
+    private static final char[] FALSE_LOWER = {'f', 'a', 'l', 's', 'e'};
+    private static final char[] FALSE_UPPER = {'F', 'A', 'L', 'S', 'E'};
+
+    private final char[] data;
+    private int position;
+
+    private State[] states = new State[32];
+    private int currentState;
+
+    JsonTokenizer(String json) {
+        data = json.toCharArray();
+        position = 0;
+        currentState = 0;
+        states[currentState] = State.NONE;
+    }
+
+    /**
+     * read next value from the stream
+     * @return next Token for the value
+     */
+    JsonToken nextToken() {
+        while (position < data.length) {
+            // skip whitespace
+            skipWhitespace();
+            JsonToken token = nextValue();
+            // only string could be used as an object member name
+            if (states[currentState] == State.OBJECT_MEMBER_NAME) {
+                if (token.type != TokenType.OBJECT_END
+                        && token.type != TokenType.OBJECT_START
+                        && token.type != TokenType.STRING) {
+                    throw new MalformedJsonException("Unexpected '" + token.toString() + "' at " + position);
+                } else if (states[currentState] == State.OBJECT_MEMBER_NAME
+                        && token.type == TokenType.STRING) {
+                    states[currentState] = State.OBJECT_MEMBER_DELIMITER;
+                }
+            } else if(token.type != TokenType.ARRAY_START
+                    && states[currentState] == State.ARRAY_VALUE) {
+                states[currentState] = State.ARRAY_DELIMITER;
+            }
+            return token;
+        }
+
+        if(states[currentState] != State.NONE) {
+            switch (states[currentState]) {
+                case ARRAY_VALUE:
+                    throw new MalformedJsonException("']' expected");
+                case ARRAY_DELIMITER:
+                    throw new MalformedJsonException("Next array value expected, ',' found");
+                case OBJECT_MEMBER_DELIMITER:
+                    throw new MalformedJsonException("Next object member value expected, ',' found");
+                case OBJECT_MEMBER_NAME:
+                    throw new MalformedJsonException("Next object member name expected, ',' found");
+                case OBJECT_MEMBER_VALUE:
+                    throw new MalformedJsonException("'}' expected");
+            }
+        }
+
+        return new JsonToken(TokenType.NONE, position, position);
+    }
+
+    private void pushState(State state) {
+        // grow states array if needed, don't use array list purely for the performance
+        currentState++;
+        if(currentState >= states.length) {
+            State[] newStates = new State[states.length << 2];
+            System.arraycopy(states, 0, newStates, 0, states.length);
+            states = newStates;
+        }
+        states[currentState] = state;
+    }
+
+    private void popState() {
+        currentState--;
+    }
+
+    private void skipWhitespace() {
+        int length = data.length;
+        while(position < length
+                && (data[position] == ' '
+                    || data[position] == '\t'
+                    || data[position] == '\r'
+                    || data[position] == '\n'
+                    || data[position] == '\f')) {
+            position++;
+        }
+    }
+
+    private JsonToken nextValue() {
+        if(position >= data.length) {
+            throw new MalformedJsonException("Unexpected end of document");
+        }
+
+        switch (data[position]) {
+            case '{':
+                return startObject();
+            case '[':
+                return startArray();
+            case ']':
+                return arrayEnd();
+            case '}':
+                return objectEnd();
+            case ':':
+                if(states[currentState] != State.OBJECT_MEMBER_DELIMITER) {
+                    throw new MalformedJsonException("Unexpected ':' at " + position);
+                }
+                states[currentState] = State.OBJECT_MEMBER_VALUE;
+                position++;
+                skipWhitespace();
+                return nextValue();
+            case ',':
+                if(states[currentState] == State.OBJECT_MEMBER_VALUE) {
+                    states[currentState] = State.OBJECT_MEMBER_NAME;
+                } else if(states[currentState] == State.ARRAY_DELIMITER) {
+                    states[currentState] = State.ARRAY_VALUE;
+                } else {
+                    throw new MalformedJsonException("Unexpected ',' at " + position);
+                }
+                position++;
+                skipWhitespace();
+                return nextValue();
+            case '\"':
+                return stringValue();
+            case 'n':
+            case 'N':
+                return nullValue();
+            case 't':
+            case 'T':
+                return trueValue();
+            case 'f':
+            case 'F':
+                return falseValue();
+            case '0':
+            case '1':
+            case '2':
+            case '3':
+            case '4':
+            case '5':
+            case '6':
+            case '7':
+            case '8':
+            case '9':
+            case '-':
+                return numericValue();
+            default:
+                throw new MalformedJsonException("Unexpected symbol '" + data[position] + "' at " + position);
+        }
+    }
+
+    private JsonToken objectEnd() {
+        if(states[currentState] == State.OBJECT_MEMBER_VALUE
+                || states[currentState] == State.OBJECT_MEMBER_NAME) {
+            popState();
+        } else {
+            throw new MalformedJsonException("Unexpected '}' at " + position);
+        }
+        return new JsonToken(TokenType.OBJECT_END, position, position++);
+    }
+
+    private JsonToken arrayEnd() {
+        if(states[currentState] == State.ARRAY_DELIMITER || states[currentState] == State.ARRAY_VALUE) {
+            popState();
+        } else {
+            throw new MalformedJsonException("Unexpected ']' at " + position);
+        }
+        return new JsonToken(TokenType.ARRAY_END, position, position++);
+    }
+
+    private JsonToken startObject() {
+        checkValueState('{');
+        pushState(State.OBJECT_MEMBER_NAME);
+        return new JsonToken(TokenType.OBJECT_START, position, position++);
+    }
+
+    private JsonToken startArray() {
+        checkValueState('[');
+        pushState(State.ARRAY_VALUE);
+        return new JsonToken(TokenType.ARRAY_START, position, position++);
+    }
+
+    private JsonToken numericValue() {
+        checkValueState(data[position]);
+        /*
+         * number
+         *     integer fraction exponent
+         *
+         * integer
+         *     digit
+         *     onenine digits
+         *     '-' digit
+         *     '-' onenine digits
+         *
+         * digits
+         *     digit
+         *     digit digits
+         *
+         * digit
+         *     '0'
+         *     onenine
+         *
+         * onenine
+         *
+         *
+         * fraction
+         *     ""
+         *     '.' digits
+         *
+         * exponent
+         *     ""
+         *     'E' sign digits
+         *     'e' sign digits
+         *
+         * sign
+         *     ""
+         *     '+'
+         *     '-'
+         */
+
+        int startPosition = position;
+        NumberState state = NumberState.NONE;
+
+        while (state != NumberState.DONE) {
+            switch (data[position]) {
+                case '0':
+                    if(state == NumberState.NONE
+                            || state == NumberState.MINUS) {
+                        state = NumberState.ZERO;
+                    } else if(state == NumberState.EXPONENT) {
+                        state = NumberState.EXP_SIGN;
+                    } else if(state != NumberState.DIGITS
+                            && state != NumberState.FRACTION
+                            && state != NumberState.EXP_SIGN) {
+                        throw new MalformedJsonException("Wrong number format at position " + position);
+                    }
+                    break;
+                case '1':
+                case '2':
+                case '3':
+                case '4':
+                case '5':
+                case '6':
+                case '7':
+                case '8':
+                case '9':
+                    if(state == NumberState.NONE
+                            || state == NumberState.MINUS) {
+                        state = NumberState.DIGITS;
+                    }
+                    if(state == NumberState.EXPONENT) {
+                        state = NumberState.EXP_SIGN;
+                    }
+                    break;
+                case '-':
+                    if(state == NumberState.NONE) {
+                        state = NumberState.MINUS;
+                    } else if(state == NumberState.EXPONENT) {
+                        state = NumberState.EXP_SIGN;
+                    } else {
+                        throw new MalformedJsonException("Wrong number format at position " + position);
+                    }
+                    break;
+                case '+':
+                    if(state != NumberState.EXPONENT) {
+                        throw new MalformedJsonException("Wrong number format at position " + position);
+                    }
+                    state = NumberState.EXP_SIGN;
+                    break;
+                case '.':
+                    if(state != NumberState.ZERO
+                            && state != NumberState.DIGITS) {
+                        throw new MalformedJsonException("Wrong number format at position " + position);
+                    }
+                    state = NumberState.FRACTION;
+                    break;
+                case 'e':
+                case 'E':
+                    if(state != NumberState.NONE
+                            && state != NumberState.ZERO
+                            && state != NumberState.DIGITS
+                            && state != NumberState.FRACTION) {
+                        throw new MalformedJsonException("Wrong number format at position " + position);
+                    }
+                    state = NumberState.EXPONENT;
+                    break;
+
+                case '}':
+                case ']':
+                case ':':
+                case ',':
+                case ' ':
+                case '\t':
+                case '\f':
+                case '\r':
+                case '\n':
+                    state = NumberState.DONE;
+                    position--; // this char should be consumed by outer call
+                    break;
+                default:
+                    throw new MalformedJsonException("Wrong number format at position " + position);
+            }
+
+            if(position >= data.length - 1) {
+                if(state == NumberState.DIGITS
+                        || state == NumberState.ZERO
+                        || state == NumberState.FRACTION
+                        || state == NumberState.EXP_SIGN) {
+                    state = NumberState.DONE;
+                }
+                if(state != NumberState.DONE) {
+                    throw new MalformedJsonException("Wrong number format at position " + position);
+                }
+            }
+            position++;
+        }
+
+        return new JsonToken(TokenType.NUMBER, startPosition, position);
+    }
+
+    private JsonToken nullValue() {
+        checkValueState('n');
+        return keyword(TokenType.NULL, NULL_LOWER, NULL_UPPER);
+    }
+
+    private JsonToken trueValue() {
+        checkValueState('t');
+        return keyword(TokenType.TRUE, TRUE_LOWER, TRUE_UPPER);
+    }
+
+    private JsonToken falseValue() {
+        checkValueState('f');
+        return keyword(TokenType.FALSE, FALSE_LOWER, FALSE_UPPER);
+    }
+
+    private JsonToken keyword(TokenType type, char[] keywordLower, char[] keywordUpper) {
+        int length = keywordLower.length;
+
+        if(data.length < position + length) {
+            throw new MalformedJsonException("unknown value at position " + position);
+        }
+
+        for(int i = 0; i< length; i++) {
+            if(data[position + i] != keywordLower[i]
+                    && data[position + i] != keywordUpper[i]) {
+                throw new MalformedJsonException("unknown value at position " + position + "(" + new String(keywordLower) + " expected)");
+            }
+        }
+
+        position += length;
+        if(data.length > position && isLiteral(data[position])) {
+            throw new MalformedJsonException("unknown value at position " + position);
+        }
+        return new JsonToken(type, position - length, position);
+    }
+
+    private void checkValueState(char unexpected) {
+        State state = states[currentState];
+        if(state == State.ARRAY_DELIMITER
+                || state == State.OBJECT_MEMBER_NAME
+                || state == State.OBJECT_MEMBER_DELIMITER) {
+            throw new MalformedJsonException("Unexpected '" + unexpected + "' at " + position);
+        }
+    }
+
+    private boolean isLiteral(char c) {
+        switch (c) {
+            case '}':
+            case ']':
+            case ',':
+            case ' ':
+            case '\t':
+            case '\f':
+            case '\r':
+            case '\n':
+                return false;
+            default:
+                return true;
+        }
+    }
+
+    private JsonToken stringValue() {
+        int startPosition = ++position; // skip open quote
+        while(position < data.length) {
+            switch (data[position]) {
+                // escape
+                case '\\':
+                    /*
+                     * escape
+                     *     '"'
+                     *     '\'
+                     *     '/'
+                     *     'b'
+                     *     'f'
+                     *     'n'
+                     *     'r'
+                     *     't'
+                     *     'u' hex hex hex hex
+                     */
+                    switch (data[++position]) {
+                        case '"':
+                        case '\\':
+                        case '/':
+                        case 'b':
+                        case 'f':
+                        case 'n':
+                        case 'r':
+                        case 't':
+                            position++;
+                            continue;
+                        case 'u':
+                            for(int i=0; i<4; i++) {
+                                char next = data[position + i];
+                                if ((next < '0' || next > '9')
+                                        && (next < 'a' || next > 'f')
+                                        && (next < 'A' || next > 'F')
+                                ) {
+                                    throw new MalformedJsonException("Unknown escape sequence "
+                                            + String.valueOf(data, position - 1, position + 4) + " at position " + position);
+                                }
+                            }
+                            position += 4;
+                            continue;
+                        default:
+                            throw new MalformedJsonException("Unknown escape sequence "
+                                    + String.valueOf(data, position - 1, position) + " at position " + position);
+                    }
+                case '"':
+                    return new JsonToken(TokenType.STRING, startPosition, position++);
+            }
+            position++;
+        }
+        throw new MalformedJsonException("Unexpected end of string literal");
+    }
+
+    enum State {
+        NONE,
+        OBJECT_MEMBER_NAME,
+        OBJECT_MEMBER_DELIMITER,
+        OBJECT_MEMBER_VALUE,
+        ARRAY_VALUE,
+        ARRAY_DELIMITER
+    }
+
+    public enum TokenType {
+        NONE,
+        ARRAY_START,
+        ARRAY_END,
+        OBJECT_START,
+        OBJECT_END,
+        STRING,
+        NUMBER,
+        TRUE,
+        FALSE,
+        NULL
+    }
+
+    enum NumberState {
+        NONE,
+        ZERO,
+        DIGITS,
+        MINUS,
+        FRACTION,
+        EXPONENT,
+        EXP_SIGN,
+        DONE
+    }
+
+    class JsonToken implements Comparable<JsonToken> {
+        final TokenType type;
+        final int from;
+        final int to;
+
+        JsonToken(TokenType type, int from, int to) {
+            this.type = type;
+            this.from = from;
+            this.to = to;
+        }
+
+        public TokenType getType() {
+            return type;
+        }
+
+        char[] getData() {
+            return data;
+        }
+
+        int length() {
+            return to - from;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+
+            JsonToken token = (JsonToken) o;
+            if(type != token.type) {
+                return false;
+            }
+            if(length() != token.length()) {
+                return false;
+            }
+            for(int i=from, j=token.from; i<to && j<token.to; i++, j++) {
+                if(data[i] != token.getData()[j]) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        @Override
+        public int hashCode() {
+            int result = type.hashCode();
+            for(int i=from; i<to; i++) {
+                result = 31 * result + data[i];
+            }
+            return result;
+        }
+
+        @Override
+        public int compareTo(JsonToken o) {
+            if(type != o.type) {
+                return type.ordinal() - o.type.ordinal();
+            }
+
+            int diff = length() - o.length();
+            if(diff != 0) {
+                return diff;
+            }
+
+            for(int i=from, j=o.from; i<to && j<o.to; i++, j++) {
+                diff = data[i] - o.getData()[j];
+                if(diff != 0) {
+                    return diff;
+                }
+            }
+
+            return 0;
+        }
+
+        @Override
+        public String toString() {
+            return new String(data, from, length());
+        }
+    }
+
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonUtils.java b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonUtils.java
new file mode 100644
index 0000000..bbcb0cf
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/json/JsonUtils.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+/**
+ * Simple utils to process JSON.
+ *
+ * @since 4.2
+ * @see org.apache.cayenne.value.Json
+ */
+public final class JsonUtils {
+
+    /**
+     * Cleanup and reformat any valid JSON string.
+     * Generally this methods just removes unnecessary whitespaces in the document.
+     *
+     * @param json valid JSON document
+     * @return normalized JSON
+     */
+    public static String normalize(String json) {
+        return new JsonFormatter(json).process();
+    }
+
+    /**
+     * <p>
+     * Method that compares two JSON documents.
+     * <br>
+     * This methods will parse documents so it will ignores object keys ordering and whitespaces.
+     * </p>
+     * <b>NOTE</b> this method doesn't parse numbers so same numbers in different format will be different.
+     *
+     * @param json1 first value
+     * @param json2 second value
+     * @return true if documents are equal
+     */
+    public static boolean compare(String json1, String json2) {
+        Object object1 = new JsonReader(json1).process();
+        Object object2 = new JsonReader(json2).process();
+        return object1.equals(object2);
+    }
+
+    private JsonUtils() {
+    }
+
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/value/Json.java b/cayenne-server/src/main/java/org/apache/cayenne/value/json/MalformedJsonException.java
similarity index 55%
copy from cayenne-server/src/main/java/org/apache/cayenne/value/Json.java
copy to cayenne-server/src/main/java/org/apache/cayenne/value/json/MalformedJsonException.java
index 9252667..75087a0 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/value/Json.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/value/json/MalformedJsonException.java
@@ -17,42 +17,16 @@
  *    under the License.
  */
 
-package org.apache.cayenne.value;
-
-import java.util.Objects;
+package org.apache.cayenne.value.json;
 
 /**
- * A Cayenne-supported values object that holds Json string.
- *
  * @since 4.2
  */
-public class Json {
-
-    private final String json;
-
-    public Json(String json) {
-        this.json = json;
-    }
+public final class MalformedJsonException extends RuntimeException {
 
-    public String getRawJson() {
-        return json;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        if (this == o) return true;
-        if (o == null || getClass() != o.getClass()) return false;
-        Json other = (Json) o;
-        return Objects.equals(json, other.json);
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(json);
-    }
+    private static final long serialVersionUID = 1L;
 
-    @Override
-    public String toString() {
-        return "JSON value: " + json;
+    public MalformedJsonException(String msg) {
+        super(msg);
     }
 }
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonReaderTest.java b/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonReaderTest.java
new file mode 100644
index 0000000..727ddee
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonReaderTest.java
@@ -0,0 +1,427 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * @since 4.2
+ */
+public class JsonReaderTest {
+
+    @Test
+    public void testKeyword() {
+        Object object = new JsonReader("true").process();
+        assertEquals("true", object.toString());
+    }
+
+    @Test
+    public void testNumber() {
+        Object object = new JsonReader("-123.321e12").process();
+        assertEquals("-123.321e12", object.toString());
+    }
+
+    @Test
+    public void testObject() {
+        Object object = new JsonReader("{\"abc\": 123}").process();
+        assertTrue(object instanceof Map);
+
+        @SuppressWarnings("unchecked")
+        Map<Object, Object> map = (Map<Object, Object>)object;
+        assertEquals(1, map.size());
+        assertEquals("abc", map.keySet().iterator().next().toString());
+        assertEquals("123", map.values().iterator().next().toString());
+    }
+
+    @Test
+    public void testArray() {
+        Object object = new JsonReader("[\"abc\", 123]").process();
+        assertTrue(object instanceof List);
+
+        @SuppressWarnings("unchecked")
+        List<Object> list = (List<Object>)object;
+        assertEquals(2, list.size());
+        Iterator<Object> iterator = list.iterator();
+        assertEquals("abc", iterator.next().toString());
+        assertEquals("123", iterator.next().toString());
+    }
+
+    @Test
+    public void testArrayOfObjects() {
+        Object object = new JsonReader("[{\"abc\": 123}, {\"abc\":321}, {\"abc\":-123}]").process();
+        assertTrue(object instanceof List);
+
+        @SuppressWarnings("unchecked")
+        List<Object> list = (List<Object>)object;
+        assertEquals(3, list.size());
+    }
+
+    @Test
+    public void testObjectWithArray() {
+        Object object = new JsonReader("{\"abc\": [], \"def\":[1,2,3], \"ghi\":[\"test\"]}").process();
+        assertTrue(object instanceof Map);
+
+        @SuppressWarnings("unchecked")
+        Map<Object, Object> list = (Map<Object, Object>)object;
+        assertEquals(3, list.size());
+    }
+
+    @Test
+    public void testComplexJson() {
+        Object object = new JsonReader(JSON).process();
+        assertTrue(object instanceof List);
+
+        @SuppressWarnings("unchecked")
+        List<Object> list = (List<Object>)object;
+        assertEquals(7, list.size());
+
+        for(Object next : list) {
+            assertTrue(next instanceof Map);
+            @SuppressWarnings("unchecked")
+            Map<Object, Object> map = (Map<Object, Object>)next;
+            assertEquals(22, map.size());
+        }
+    }
+
+    private static final String JSON = "[\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffde690418e483588a\",\n" +
+            "    \"index\": 0,\n" +
+            "    \"guid\": \"e7cb7511-5b58-482b-9662-bbabc17c7999\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$2,019.14\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 40,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Briana Jimenez\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"VERBUS\",\n" +
+            "    \"email\": \"brianajimenez@verbus.com\",\n" +
+            "    \"phone\": \"+1 (911) 471-2705\",\n" +
+            "    \"address\": \"178 Ashland Place, Cetronia, Alaska, 7446\",\n" +
+            "    \"about\": \"Do ullamco et nulla incididunt dolore culpa voluptate et cupidatat excepteur labore proident. Nisi exercitation tempor duis est reprehenderit exercitation aliquip velit veniam. Fugiat mollit pariatur enim qui excepteur minim officia sunt mollit sint do.\\r\\n\",\n" +
+            "    \"registered\": \"2016-12-21T04:56:36 -03:00\",\n" +
+            "    \"latitude\": -68.436891,\n" +
+            "    \"longitude\": -40.276385,\n" +
+            "    \"tags\": [\n" +
+            "      \"incididunt\",\n" +
+            "      \"voluptate\",\n" +
+            "      \"irure\",\n" +
+            "      \"eu\",\n" +
+            "      \"voluptate\",\n" +
+            "      \"do\",\n" +
+            "      \"mollit\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Hyde Thompson\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Cathleen Mercer\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Emilia Mckenzie\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Briana Jimenez! You have 3 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"apple\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffad699bf86a1de6f1\",\n" +
+            "    \"index\": 1,\n" +
+            "    \"guid\": \"7a25ff47-980f-4163-b679-5b82e6bc0693\",\n" +
+            "    \"isActive\": true,\n" +
+            "    \"balance\": \"$3,888.34\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 20,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Finley Hawkins\",\n" +
+            "    \"gender\": \"male\",\n" +
+            "    \"company\": \"OMATOM\",\n" +
+            "    \"email\": \"finleyhawkins@omatom.com\",\n" +
+            "    \"phone\": \"+1 (904) 545-2548\",\n" +
+            "    \"address\": \"552 Pilling Street, Roosevelt, American Samoa, 3424\",\n" +
+            "    \"about\": \"Aliquip ad cillum minim exercitation officia proident laborum excepteur est laborum irure laboris. Nisi pariatur labore Lorem et ad exercitation. Occaecat ullamco exercitation ut in anim eiusmod sint pariatur dolor Lorem elit incididunt nulla.\\r\\n\",\n" +
+            "    \"registered\": \"2019-03-22T09:16:38 -03:00\",\n" +
+            "    \"latitude\": 8.588498,\n" +
+            "    \"longitude\": 140.490892,\n" +
+            "    \"tags\": [\n" +
+            "      \"elit\",\n" +
+            "      \"ex\",\n" +
+            "      \"dolore\",\n" +
+            "      \"elit\",\n" +
+            "      \"minim\",\n" +
+            "      \"excepteur\",\n" +
+            "      \"minim\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Iris Fletcher\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Moss Whitfield\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Esmeralda Christensen\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Finley Hawkins! You have 5 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"banana\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffb2a31b910a2159f1\",\n" +
+            "    \"index\": 2,\n" +
+            "    \"guid\": \"ab53f6a7-25e2-41e9-9744-7fbe01b16f6b\",\n" +
+            "    \"isActive\": true,\n" +
+            "    \"balance\": \"$1,083.29\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 34,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Wendi Bowen\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"ZILLANET\",\n" +
+            "    \"email\": \"wendibowen@zillanet.com\",\n" +
+            "    \"phone\": \"+1 (874) 458-3093\",\n" +
+            "    \"address\": \"601 Fountain Avenue, Boonville, Maine, 6733\",\n" +
+            "    \"about\": \"Eu exercitation est duis occaecat excepteur tempor sint culpa. Dolore ullamco irure pariatur reprehenderit esse qui. Exercitation tempor non duis elit exercitation cupidatat sunt ad adipisicing id. Mollit mollit reprehenderit voluptate sunt dolor nulla id. Tempor officia elit ut officia Lorem in veniam.\\r\\n\",\n" +
+            "    \"registered\": \"2017-08-26T10:33:44 -03:00\",\n" +
+            "    \"latitude\": -85.532155,\n" +
+            "    \"longitude\": -127.824759,\n" +
+            "    \"tags\": [\n" +
+            "      \"est\",\n" +
+            "      \"exercitation\",\n" +
+            "      \"reprehenderit\",\n" +
+            "      \"aliqua\",\n" +
+            "      \"irure\",\n" +
+            "      \"in\",\n" +
+            "      \"non\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Bridget Todd\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Mccall Dennis\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Willis Cohen\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Wendi Bowen! You have 3 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"strawberry\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffb828546ee5d53781\",\n" +
+            "    \"index\": 3,\n" +
+            "    \"guid\": \"63d5c010-6b97-4fdd-9733-52824dfdab01\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$3,395.34\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 38,\n" +
+            "    \"eyeColor\": \"blue\",\n" +
+            "    \"name\": \"Harrell Zamora\",\n" +
+            "    \"gender\": \"male\",\n" +
+            "    \"company\": \"LUDAK\",\n" +
+            "    \"email\": \"harrellzamora@ludak.com\",\n" +
+            "    \"phone\": \"+1 (839) 494-3495\",\n" +
+            "    \"address\": \"698 Kenilworth Place, Whitmer, Hawaii, 963\",\n" +
+            "    \"about\": \"Ex nisi minim adipisicing et amet sint sunt minim deserunt dolore. Incididunt tempor dolore tempor ipsum officia mollit non. Officia aute aute consequat amet mollit sit officia. Nostrud in laborum do duis.\\r\\n\",\n" +
+            "    \"registered\": \"2014-09-08T08:52:48 -03:00\",\n" +
+            "    \"latitude\": 48.622813,\n" +
+            "    \"longitude\": -52.26753,\n" +
+            "    \"tags\": [\n" +
+            "      \"proident\",\n" +
+            "      \"nulla\",\n" +
+            "      \"enim\",\n" +
+            "      \"nostrud\",\n" +
+            "      \"fugiat\",\n" +
+            "      \"qui\",\n" +
+            "      \"dolore\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Burris Mercado\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Bertie Schroeder\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Estrada Hampton\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Harrell Zamora! You have 1 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"apple\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ff8f09e316ea28bbaf\",\n" +
+            "    \"index\": 4,\n" +
+            "    \"guid\": \"0fc17d32-5a40-4d30-85ac-f4f3f6d146b1\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$3,671.08\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 33,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Bernice Allison\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"INSOURCE\",\n" +
+            "    \"email\": \"berniceallison@insource.com\",\n" +
+            "    \"phone\": \"+1 (816) 459-3811\",\n" +
+            "    \"address\": \"417 Gerry Street, Lavalette, South Dakota, 7943\",\n" +
+            "    \"about\": \"Id amet magna dolor occaecat aute dolor ullamco voluptate irure Lorem sunt. Elit aliqua ad Lorem irure aute eu. Incididunt ullamco elit dolore consectetur ipsum anim non incididunt dolor sit in consequat. Eiusmod ea ut fugiat voluptate cupidatat ullamco esse in. Ea voluptate excepteur duis labore excepteur occaecat. Tempor est ad anim eu ea. Eu irure do reprehenderit veniam velit ex eu incididunt officia eiusmod aliquip excepteur nisi.\\r\\n\",\n" +
+            "    \"registered\": \"2015-03-27T10:29:54 -03:00\",\n" +
+            "    \"latitude\": 24.183295,\n" +
+            "    \"longitude\": 82.144996,\n" +
+            "    \"tags\": [\n" +
+            "      \"enim\",\n" +
+            "      \"occaecat\",\n" +
+            "      \"laborum\",\n" +
+            "      \"velit\",\n" +
+            "      \"fugiat\",\n" +
+            "      \"anim\",\n" +
+            "      \"sint\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Christine Horton\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Fowler Good\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Antoinette Cooper\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Bernice Allison! You have 9 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"banana\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ff88489df59c89193c\",\n" +
+            "    \"index\": 5,\n" +
+            "    \"guid\": \"1b3b06d6-88d2-4b89-ade9-252de03c11b2\",\n" +
+            "    \"isActive\": true,\n" +
+            "    \"balance\": \"$3,814.72\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 26,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Thornton Alexander\",\n" +
+            "    \"gender\": \"male\",\n" +
+            "    \"company\": \"ASSITIA\",\n" +
+            "    \"email\": \"thorntonalexander@assitia.com\",\n" +
+            "    \"phone\": \"+1 (862) 524-2047\",\n" +
+            "    \"address\": \"247 Beach Place, Barronett, Guam, 272\",\n" +
+            "    \"about\": \"Consequat nulla occaecat aliquip fugiat fugiat ipsum. Veniam incididunt ad est enim sit aliquip exercitation et do sint voluptate. Nostrud culpa velit cillum Lorem labore laborum id voluptate ad et.\\r\\n\",\n" +
+            "    \"registered\": \"2018-01-17T05:36:12 -03:00\",\n" +
+            "    \"latitude\": -55.650877,\n" +
+            "    \"longitude\": 42.279245,\n" +
+            "    \"tags\": [\n" +
+            "      \"do\",\n" +
+            "      \"qui\",\n" +
+            "      \"id\",\n" +
+            "      \"eiusmod\",\n" +
+            "      \"labore\",\n" +
+            "      \"consequat\",\n" +
+            "      \"ullamco\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Helen Copeland\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Hall Joseph\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Ursula Mckee\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Thornton Alexander! You have 7 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"strawberry\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ff9e6d40f38aceb25f\",\n" +
+            "    \"index\": 6,\n" +
+            "    \"guid\": \"cd160d1b-7d66-4746-866c-2390d236e6db\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$1,385.92\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 38,\n" +
+            "    \"eyeColor\": \"blue\",\n" +
+            "    \"name\": \"Ester Cooke\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"DAYCORE\",\n" +
+            "    \"email\": \"estercooke@daycore.com\",\n" +
+            "    \"phone\": \"+1 (814) 450-3865\",\n" +
+            "    \"address\": \"922 Coleman Street, Johnsonburg, Georgia, 8863\",\n" +
+            "    \"about\": \"Commodo nisi officia deserunt pariatur cillum adipisicing incididunt. Duis pariatur duis consectetur dolor magna aute sunt. Enim occaecat mollit veniam qui voluptate. Ea id fugiat laborum eu aute esse mollit id consequat deserunt. Amet incididunt cupidatat fugiat do Lorem veniam dolor aliquip aliquip magna anim velit. Sint do commodo tempor tempor tempor irure cillum velit consequat sunt ut est.\\r\\n\",\n" +
+            "    \"registered\": \"2014-08-23T08:53:19 -03:00\",\n" +
+            "    \"latitude\": 26.474005,\n" +
+            "    \"longitude\": -122.921901,\n" +
+            "    \"tags\": [\n" +
+            "      \"esse\",\n" +
+            "      \"elit\",\n" +
+            "      \"adipisicing\",\n" +
+            "      \"sunt\",\n" +
+            "      \"incididunt\",\n" +
+            "      \"esse\",\n" +
+            "      \"quis\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Harding Sampson\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Rosario Hansen\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Larsen Black\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Ester Cooke! You have 8 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"banana\"\n" +
+            "  }\n" +
+            "]";
+}
\ No newline at end of file
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonTokenizerTest.java b/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonTokenizerTest.java
new file mode 100644
index 0000000..daea7cb
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonTokenizerTest.java
@@ -0,0 +1,657 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @since 4.2
+ */
+public class JsonTokenizerTest {
+
+    @Test
+    public void testNull() {
+        JsonTokenizer tokenizer;
+        JsonTokenizer.JsonToken token;
+
+        tokenizer = new JsonTokenizer("null");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NULL, token.type);
+
+        tokenizer = new JsonTokenizer("NULL");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NULL, token.type);
+
+        tokenizer = new JsonTokenizer("  nUlL  ");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NULL, token.type);
+    }
+
+    @Test
+    public void testTrue() {
+        JsonTokenizer tokenizer;
+        JsonTokenizer.JsonToken token;
+
+        tokenizer = new JsonTokenizer("true");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.TRUE, token.type);
+
+        tokenizer = new JsonTokenizer("TRUE");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.TRUE, token.type);
+
+        tokenizer = new JsonTokenizer("  tRuE  ");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.TRUE, token.type);
+    }
+
+    @Test
+    public void testFalse() {
+        JsonTokenizer tokenizer;
+        JsonTokenizer.JsonToken token;
+
+        tokenizer = new JsonTokenizer("false");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.FALSE, token.type);
+
+        tokenizer = new JsonTokenizer("FALSE");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.FALSE, token.type);
+
+        tokenizer = new JsonTokenizer("  fAlSe  ");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.FALSE, token.type);
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testNotNull() {
+        JsonTokenizer tokenizer = new JsonTokenizer("  nUlLs  ");
+        tokenizer.nextToken();
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnknown() {
+        JsonTokenizer tokenizer = new JsonTokenizer("  abc  ");
+        tokenizer.nextToken();
+    }
+
+    @Test
+    public void testNumber() {
+        JsonTokenizer tokenizer;
+        JsonTokenizer.JsonToken token;
+
+        tokenizer = new JsonTokenizer("0");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("0", token.toString());
+
+        tokenizer = new JsonTokenizer("123");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("123", token.toString());
+
+        tokenizer = new JsonTokenizer("-0");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-0", token.toString());
+
+        tokenizer = new JsonTokenizer("-123");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-123", token.toString());
+
+        tokenizer = new JsonTokenizer("0.0123");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("0.0123", token.toString());
+
+        tokenizer = new JsonTokenizer("0.");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("0.", token.toString());
+
+        tokenizer = new JsonTokenizer("123.123");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("123.123", token.toString());
+
+        tokenizer = new JsonTokenizer("-0.0123");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-0.0123", token.toString());
+
+        tokenizer = new JsonTokenizer("-123.123");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-123.123", token.toString());
+
+        tokenizer = new JsonTokenizer("123e13");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("123e13", token.toString());
+
+        tokenizer = new JsonTokenizer("-123e13");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-123e13", token.toString());
+
+        tokenizer = new JsonTokenizer("-123e-13");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-123e-13", token.toString());
+
+        tokenizer = new JsonTokenizer("-123e+13");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-123e+13", token.toString());
+
+        tokenizer = new JsonTokenizer("  -0.001e+001  ");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token.type);
+        assertEquals("-0.001e+001", token.toString());
+    }
+
+    @Test
+    public void testString() {
+        JsonTokenizer tokenizer;
+        JsonTokenizer.JsonToken token;
+
+        tokenizer = new JsonTokenizer("\"123\"");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.STRING, token.type);
+        assertEquals("123", token.toString());
+
+        tokenizer = new JsonTokenizer("\"test\\ttest\\ntest\"");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.STRING, token.type);
+        assertEquals("test\\ttest\\ntest", token.toString());
+
+        tokenizer = new JsonTokenizer("\"test\\\"test\\\"test\"");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.STRING, token.type);
+        assertEquals("test\\\"test\\\"test", token.toString());
+
+        tokenizer = new JsonTokenizer("\"test\\\\test\\\\test\"");
+        token = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.STRING, token.type);
+        assertEquals("test\\\\test\\\\test", token.toString());
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedString() {
+        JsonTokenizer tokenizer = new JsonTokenizer("\"1234567");
+        tokenizer.nextToken();
+    }
+
+    @Test
+    public void testEmptyObject() {
+        JsonTokenizer tokenizer = new JsonTokenizer("{}");
+        JsonTokenizer.JsonToken token1 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token2 = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.OBJECT_START, token1.type);
+        assertEquals(JsonTokenizer.TokenType.OBJECT_END, token2.type);
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedObject() {
+        JsonTokenizer tokenizer = new JsonTokenizer("{");
+        tokenizer.nextToken();
+        tokenizer.nextToken();
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedObject2() {
+        JsonTokenizer tokenizer = new JsonTokenizer("{\"abc\"");
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing
+        }
+    }
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedObject3() {
+        JsonTokenizer tokenizer = new JsonTokenizer("{\"abc\":");
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing
+        }
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedObject4() {
+        JsonTokenizer tokenizer = new JsonTokenizer("{\"abc\":123");
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing
+        }
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedObject5() {
+        JsonTokenizer tokenizer = new JsonTokenizer("{\"abc\":123,");
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing
+        }
+    }
+
+    @Test
+    public void testObject() {
+        JsonTokenizer tokenizer = new JsonTokenizer("  {\"test\": 123,\n\t\"2\": \"abc\"  } ");
+        JsonTokenizer.JsonToken token1 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token2 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token3 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token4 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token5 = tokenizer.nextToken();
+
+        assertEquals(JsonTokenizer.TokenType.OBJECT_START, token1.type);
+        assertEquals(JsonTokenizer.TokenType.STRING, token2.type);
+        assertEquals("test", token2.toString());
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token3.type);
+        assertEquals("123", token3.toString());
+        assertEquals(JsonTokenizer.TokenType.STRING, token4.type);
+        assertEquals("2", token4.toString());
+        assertEquals(JsonTokenizer.TokenType.STRING, token5.type);
+        assertEquals("abc", token5.toString());
+    }
+
+    @Test
+    public void testEmptyArray() {
+        JsonTokenizer tokenizer = new JsonTokenizer("[]");
+        JsonTokenizer.JsonToken token1 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token2 = tokenizer.nextToken();
+        assertEquals(JsonTokenizer.TokenType.ARRAY_START, token1.type);
+        assertEquals(JsonTokenizer.TokenType.ARRAY_END, token2.type);
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedArray() {
+        JsonTokenizer tokenizer = new JsonTokenizer("[");
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing
+        }
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedArray2() {
+        JsonTokenizer tokenizer = new JsonTokenizer("[1");
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing
+        }
+    }
+
+    @Test(expected = MalformedJsonException.class)
+    public void testUnclosedArray3() {
+        JsonTokenizer tokenizer = new JsonTokenizer("[1,");
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing
+        }
+    }
+
+    @Test
+    public void testArray() {
+        JsonTokenizer tokenizer = new JsonTokenizer("[0,2]");
+        JsonTokenizer.JsonToken token1 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token2 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token3 = tokenizer.nextToken();
+        JsonTokenizer.JsonToken token4 = tokenizer.nextToken();
+
+        assertEquals(JsonTokenizer.TokenType.ARRAY_START, token1.type);
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token2.type);
+        assertEquals("0", token2.toString());
+        assertEquals(JsonTokenizer.TokenType.NUMBER, token3.type);
+        assertEquals("2", token3.toString());
+        assertEquals(JsonTokenizer.TokenType.ARRAY_END, token4.type);
+    }
+
+    @Test
+    public void testFullJson() {
+        JsonTokenizer tokenizer = new JsonTokenizer(JSON);
+        JsonTokenizer.JsonToken token;
+        while((token = tokenizer.nextToken()).getType() != JsonTokenizer.TokenType.NONE) {
+            // do nothing, just trying to read everything
+        }
+
+        assertEquals(JsonTokenizer.TokenType.NONE, token.type);
+    }
+
+
+    private static final String JSON = "[\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffde690418e483588a\",\n" +
+            "    \"index\": 0,\n" +
+            "    \"guid\": \"e7cb7511-5b58-482b-9662-bbabc17c7999\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$2,019.14\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 40,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Briana Jimenez\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"VERBUS\",\n" +
+            "    \"email\": \"brianajimenez@verbus.com\",\n" +
+            "    \"phone\": \"+1 (911) 471-2705\",\n" +
+            "    \"address\": \"178 Ashland Place, Cetronia, Alaska, 7446\",\n" +
+            "    \"about\": \"Do ullamco et nulla incididunt dolore culpa voluptate et cupidatat excepteur labore proident. Nisi exercitation tempor duis est reprehenderit exercitation aliquip velit veniam. Fugiat mollit pariatur enim qui excepteur minim officia sunt mollit sint do.\\r\\n\",\n" +
+            "    \"registered\": \"2016-12-21T04:56:36 -03:00\",\n" +
+            "    \"latitude\": -68.436891,\n" +
+            "    \"longitude\": -40.276385,\n" +
+            "    \"tags\": [\n" +
+            "      \"incididunt\",\n" +
+            "      \"voluptate\",\n" +
+            "      \"irure\",\n" +
+            "      \"eu\",\n" +
+            "      \"voluptate\",\n" +
+            "      \"do\",\n" +
+            "      \"mollit\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Hyde Thompson\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Cathleen Mercer\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Emilia Mckenzie\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Briana Jimenez! You have 3 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"apple\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffad699bf86a1de6f1\",\n" +
+            "    \"index\": 1,\n" +
+            "    \"guid\": \"7a25ff47-980f-4163-b679-5b82e6bc0693\",\n" +
+            "    \"isActive\": true,\n" +
+            "    \"balance\": \"$3,888.34\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 20,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Finley Hawkins\",\n" +
+            "    \"gender\": \"male\",\n" +
+            "    \"company\": \"OMATOM\",\n" +
+            "    \"email\": \"finleyhawkins@omatom.com\",\n" +
+            "    \"phone\": \"+1 (904) 545-2548\",\n" +
+            "    \"address\": \"552 Pilling Street, Roosevelt, American Samoa, 3424\",\n" +
+            "    \"about\": \"Aliquip ad cillum minim exercitation officia proident laborum excepteur est laborum irure laboris. Nisi pariatur labore Lorem et ad exercitation. Occaecat ullamco exercitation ut in anim eiusmod sint pariatur dolor Lorem elit incididunt nulla.\\r\\n\",\n" +
+            "    \"registered\": \"2019-03-22T09:16:38 -03:00\",\n" +
+            "    \"latitude\": 8.588498,\n" +
+            "    \"longitude\": 140.490892,\n" +
+            "    \"tags\": [\n" +
+            "      \"elit\",\n" +
+            "      \"ex\",\n" +
+            "      \"dolore\",\n" +
+            "      \"elit\",\n" +
+            "      \"minim\",\n" +
+            "      \"excepteur\",\n" +
+            "      \"minim\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Iris Fletcher\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Moss Whitfield\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Esmeralda Christensen\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Finley Hawkins! You have 5 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"banana\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffb2a31b910a2159f1\",\n" +
+            "    \"index\": 2,\n" +
+            "    \"guid\": \"ab53f6a7-25e2-41e9-9744-7fbe01b16f6b\",\n" +
+            "    \"isActive\": true,\n" +
+            "    \"balance\": \"$1,083.29\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 34,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Wendi Bowen\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"ZILLANET\",\n" +
+            "    \"email\": \"wendibowen@zillanet.com\",\n" +
+            "    \"phone\": \"+1 (874) 458-3093\",\n" +
+            "    \"address\": \"601 Fountain Avenue, Boonville, Maine, 6733\",\n" +
+            "    \"about\": \"Eu exercitation est duis occaecat excepteur tempor sint culpa. Dolore ullamco irure pariatur reprehenderit esse qui. Exercitation tempor non duis elit exercitation cupidatat sunt ad adipisicing id. Mollit mollit reprehenderit voluptate sunt dolor nulla id. Tempor officia elit ut officia Lorem in veniam.\\r\\n\",\n" +
+            "    \"registered\": \"2017-08-26T10:33:44 -03:00\",\n" +
+            "    \"latitude\": -85.532155,\n" +
+            "    \"longitude\": -127.824759,\n" +
+            "    \"tags\": [\n" +
+            "      \"est\",\n" +
+            "      \"exercitation\",\n" +
+            "      \"reprehenderit\",\n" +
+            "      \"aliqua\",\n" +
+            "      \"irure\",\n" +
+            "      \"in\",\n" +
+            "      \"non\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Bridget Todd\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Mccall Dennis\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Willis Cohen\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Wendi Bowen! You have 3 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"strawberry\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ffb828546ee5d53781\",\n" +
+            "    \"index\": 3,\n" +
+            "    \"guid\": \"63d5c010-6b97-4fdd-9733-52824dfdab01\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$3,395.34\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 38,\n" +
+            "    \"eyeColor\": \"blue\",\n" +
+            "    \"name\": \"Harrell Zamora\",\n" +
+            "    \"gender\": \"male\",\n" +
+            "    \"company\": \"LUDAK\",\n" +
+            "    \"email\": \"harrellzamora@ludak.com\",\n" +
+            "    \"phone\": \"+1 (839) 494-3495\",\n" +
+            "    \"address\": \"698 Kenilworth Place, Whitmer, Hawaii, 963\",\n" +
+            "    \"about\": \"Ex nisi minim adipisicing et amet sint sunt minim deserunt dolore. Incididunt tempor dolore tempor ipsum officia mollit non. Officia aute aute consequat amet mollit sit officia. Nostrud in laborum do duis.\\r\\n\",\n" +
+            "    \"registered\": \"2014-09-08T08:52:48 -03:00\",\n" +
+            "    \"latitude\": 48.622813,\n" +
+            "    \"longitude\": -52.26753,\n" +
+            "    \"tags\": [\n" +
+            "      \"proident\",\n" +
+            "      \"nulla\",\n" +
+            "      \"enim\",\n" +
+            "      \"nostrud\",\n" +
+            "      \"fugiat\",\n" +
+            "      \"qui\",\n" +
+            "      \"dolore\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Burris Mercado\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Bertie Schroeder\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Estrada Hampton\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Harrell Zamora! You have 1 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"apple\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ff8f09e316ea28bbaf\",\n" +
+            "    \"index\": 4,\n" +
+            "    \"guid\": \"0fc17d32-5a40-4d30-85ac-f4f3f6d146b1\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$3,671.08\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 33,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Bernice Allison\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"INSOURCE\",\n" +
+            "    \"email\": \"berniceallison@insource.com\",\n" +
+            "    \"phone\": \"+1 (816) 459-3811\",\n" +
+            "    \"address\": \"417 Gerry Street, Lavalette, South Dakota, 7943\",\n" +
+            "    \"about\": \"Id amet magna dolor occaecat aute dolor ullamco voluptate irure Lorem sunt. Elit aliqua ad Lorem irure aute eu. Incididunt ullamco elit dolore consectetur ipsum anim non incididunt dolor sit in consequat. Eiusmod ea ut fugiat voluptate cupidatat ullamco esse in. Ea voluptate excepteur duis labore excepteur occaecat. Tempor est ad anim eu ea. Eu irure do reprehenderit veniam velit ex eu incididunt officia eiusmod aliquip excepteur nisi.\\r\\n\",\n" +
+            "    \"registered\": \"2015-03-27T10:29:54 -03:00\",\n" +
+            "    \"latitude\": 24.183295,\n" +
+            "    \"longitude\": 82.144996,\n" +
+            "    \"tags\": [\n" +
+            "      \"enim\",\n" +
+            "      \"occaecat\",\n" +
+            "      \"laborum\",\n" +
+            "      \"velit\",\n" +
+            "      \"fugiat\",\n" +
+            "      \"anim\",\n" +
+            "      \"sint\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Christine Horton\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Fowler Good\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Antoinette Cooper\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Bernice Allison! You have 9 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"banana\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ff88489df59c89193c\",\n" +
+            "    \"index\": 5,\n" +
+            "    \"guid\": \"1b3b06d6-88d2-4b89-ade9-252de03c11b2\",\n" +
+            "    \"isActive\": true,\n" +
+            "    \"balance\": \"$3,814.72\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 26,\n" +
+            "    \"eyeColor\": \"green\",\n" +
+            "    \"name\": \"Thornton Alexander\",\n" +
+            "    \"gender\": \"male\",\n" +
+            "    \"company\": \"ASSITIA\",\n" +
+            "    \"email\": \"thorntonalexander@assitia.com\",\n" +
+            "    \"phone\": \"+1 (862) 524-2047\",\n" +
+            "    \"address\": \"247 Beach Place, Barronett, Guam, 272\",\n" +
+            "    \"about\": \"Consequat nulla occaecat aliquip fugiat fugiat ipsum. Veniam incididunt ad est enim sit aliquip exercitation et do sint voluptate. Nostrud culpa velit cillum Lorem labore laborum id voluptate ad et.\\r\\n\",\n" +
+            "    \"registered\": \"2018-01-17T05:36:12 -03:00\",\n" +
+            "    \"latitude\": -55.650877,\n" +
+            "    \"longitude\": 42.279245,\n" +
+            "    \"tags\": [\n" +
+            "      \"do\",\n" +
+            "      \"qui\",\n" +
+            "      \"id\",\n" +
+            "      \"eiusmod\",\n" +
+            "      \"labore\",\n" +
+            "      \"consequat\",\n" +
+            "      \"ullamco\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Helen Copeland\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Hall Joseph\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Ursula Mckee\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Thornton Alexander! You have 7 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"strawberry\"\n" +
+            "  },\n" +
+            "  {\n" +
+            "    \"_id\": \"5fc4d1ff9e6d40f38aceb25f\",\n" +
+            "    \"index\": 6,\n" +
+            "    \"guid\": \"cd160d1b-7d66-4746-866c-2390d236e6db\",\n" +
+            "    \"isActive\": false,\n" +
+            "    \"balance\": \"$1,385.92\",\n" +
+            "    \"picture\": \"http://placehold.it/32x32\",\n" +
+            "    \"age\": 38,\n" +
+            "    \"eyeColor\": \"blue\",\n" +
+            "    \"name\": \"Ester Cooke\",\n" +
+            "    \"gender\": \"female\",\n" +
+            "    \"company\": \"DAYCORE\",\n" +
+            "    \"email\": \"estercooke@daycore.com\",\n" +
+            "    \"phone\": \"+1 (814) 450-3865\",\n" +
+            "    \"address\": \"922 Coleman Street, Johnsonburg, Georgia, 8863\",\n" +
+            "    \"about\": \"Commodo nisi officia deserunt pariatur cillum adipisicing incididunt. Duis pariatur duis consectetur dolor magna aute sunt. Enim occaecat mollit veniam qui voluptate. Ea id fugiat laborum eu aute esse mollit id consequat deserunt. Amet incididunt cupidatat fugiat do Lorem veniam dolor aliquip aliquip magna anim velit. Sint do commodo tempor tempor tempor irure cillum velit consequat sunt ut est.\\r\\n\",\n" +
+            "    \"registered\": \"2014-08-23T08:53:19 -03:00\",\n" +
+            "    \"latitude\": 26.474005,\n" +
+            "    \"longitude\": -122.921901,\n" +
+            "    \"tags\": [\n" +
+            "      \"esse\",\n" +
+            "      \"elit\",\n" +
+            "      \"adipisicing\",\n" +
+            "      \"sunt\",\n" +
+            "      \"incididunt\",\n" +
+            "      \"esse\",\n" +
+            "      \"quis\"\n" +
+            "    ],\n" +
+            "    \"friends\": [\n" +
+            "      {\n" +
+            "        \"id\": 0,\n" +
+            "        \"name\": \"Harding Sampson\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 1,\n" +
+            "        \"name\": \"Rosario Hansen\"\n" +
+            "      },\n" +
+            "      {\n" +
+            "        \"id\": 2,\n" +
+            "        \"name\": \"Larsen Black\"\n" +
+            "      }\n" +
+            "    ],\n" +
+            "    \"greeting\": \"Hello, Ester Cooke! You have 8 unread messages.\",\n" +
+            "    \"favoriteFruit\": \"banana\"\n" +
+            "  }\n" +
+            "]";
+}
\ No newline at end of file
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonUtilsTest.java b/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonUtilsTest.java
new file mode 100644
index 0000000..d60c2a4
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/value/json/JsonUtilsTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ *    or more contributor license agreements.  See the NOTICE file
+ *    distributed with this work for additional information
+ *    regarding copyright ownership.  The ASF licenses this file
+ *    to you 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
+ *
+ *      https://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 org.apache.cayenne.value.json;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * @since 4.2
+ */
+public class JsonUtilsTest {
+
+    @Test
+    public void compare() {
+        assertTrue(JsonUtils.compare("[]", "[]"));
+        assertTrue(JsonUtils.compare("{}", "{}"));
+        assertFalse(JsonUtils.compare("[]", "{}"));
+
+        assertTrue(JsonUtils.compare("123", "123"));
+        assertFalse(JsonUtils.compare("123", "124"));
+
+        assertTrue(JsonUtils.compare("null", "null"));
+        assertTrue(JsonUtils.compare("true", "true"));
+        assertFalse(JsonUtils.compare("true", "false"));
+
+        assertTrue(JsonUtils.compare("\"123\"", "\"123\""));
+        assertFalse(JsonUtils.compare("123", "\"123\""));
+
+        assertTrue(JsonUtils.compare("[1,2,3]", "[1, 2, 3]"));
+        assertFalse(JsonUtils.compare("[1,2,3]", "[1,2,3,4]"));
+        assertFalse(JsonUtils.compare("[1,2,3]", "[1,2]"));
+        assertFalse(JsonUtils.compare("[1,2,3]", "[1,2,4]"));
+
+        assertTrue(JsonUtils.compare("{\"abc\":123,\"def\":321}", " {\"def\" :  321 , \n\t\"abc\" :\t123 }"));
+        assertFalse(JsonUtils.compare("{\"abc\":123}", " {\"abc\" :  124 }"));
+    }
+
+    @Test
+    public void normalize() {
+        assertEquals("[]", JsonUtils.normalize("[]"));
+        assertEquals("{}", JsonUtils.normalize("{}"));
+        assertEquals("true", JsonUtils.normalize("true"));
+        assertEquals("null", JsonUtils.normalize("null"));
+        assertEquals("false", JsonUtils.normalize("false"));
+        assertEquals("123", JsonUtils.normalize("123"));
+        assertEquals("-10.24e3", JsonUtils.normalize("-10.24e3"));
+        assertEquals("\"abc\\\"def\"", JsonUtils.normalize("\"abc\\\"def\""));
+
+        assertEquals("[1, 2.0, -0.3e3, false, null, true]",
+                JsonUtils.normalize("[1 ,  2.0  ,-0.3e3, false,\nnull,\ttrue]"));
+        assertEquals("{\"abc\": 321, \"def\": true, \"ghi\": \"jkl\"}",
+                JsonUtils.normalize("{\"abc\":321,\n\"def\":true,\n\t\"ghi\":\"jkl\"}"));
+    }
+}
\ No newline at end of file