From 95d5c1406fb12c27d1c906063c9daccde36329ca Mon Sep 17 00:00:00 2001
From: Matthew Swift <matthew.swift@forgerock.com>
Date: Thu, 06 Oct 2016 07:50:36 +0000
Subject: [PATCH] OPENDJ-2860: implement JSON attribute syntaxes and matching rules

---
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java                    |  102 ++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java                          |   95 ++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java                        |  123 +++
 opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties                         |    6 
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java                            |  253 ++++++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java     |  845 ++++++++++++++++++++++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java |  574 +++++++++++++++
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java               |  109 ++
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java                   |   75 ++
 9 files changed, 2,182 insertions(+), 0 deletions(-)

diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java
new file mode 100644
index 0000000..250770f
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImpl.java
@@ -0,0 +1,845 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
+
+import static com.fasterxml.jackson.core.JsonToken.END_ARRAY;
+import static com.fasterxml.jackson.core.JsonToken.END_OBJECT;
+import static com.forgerock.opendj.util.StringPrepProfile.prepareUnicode;
+import static org.forgerock.opendj.ldap.Assertion.UNDEFINED_ASSERTION;
+import static org.forgerock.opendj.ldap.schema.CoreSchema.getIntegerMatchingRule;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.CASE_SENSITIVE_STRINGS;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.IGNORE_WHITE_SPACE;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.INDEXED_FIELD_PATTERNS;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.LENIENT;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
+
+import org.forgerock.json.JsonPointer;
+import org.forgerock.json.JsonValue;
+import org.forgerock.json.resource.QueryFilters;
+import org.forgerock.opendj.ldap.Assertion;
+import org.forgerock.opendj.ldap.ByteSequence;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.ByteStringBuilder;
+import org.forgerock.opendj.ldap.ConditionResult;
+import org.forgerock.opendj.ldap.DecodeException;
+import org.forgerock.opendj.ldap.schema.MatchingRuleImpl;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.opendj.ldap.spi.IndexQueryFactory;
+import org.forgerock.opendj.ldap.spi.Indexer;
+import org.forgerock.opendj.ldap.spi.IndexingOptions;
+import org.forgerock.util.Options;
+import org.forgerock.util.query.QueryFilter;
+import org.forgerock.util.query.QueryFilterVisitor;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.JsonToken;
+
+/**
+ * This class implements the jsonQueryMatch equality matching rule whose assertion syntax is a
+ * CREST {@link QueryFilter} and whose string syntax is defined in {@link org.forgerock.util.query.QueryFilterParser}.
+ */
+final class JsonQueryEqualityMatchingRuleImpl implements MatchingRuleImpl {
+    // Separator bytes used when encoding JSON keys. Null sorts before false, sorts before true, etc.
+    // Package private for testing.
+    private static final int KEY_FIELD_START = 0;
+    private static final int KEY_FIELD_END = 1;
+    private static final int KEY_TYPE_NULL = 0;
+    private static final int KEY_TYPE_FALSE = 1;
+    private static final int KEY_TYPE_TRUE = 2;
+    private static final int KEY_TYPE_NUMBER = 3;
+    private static final int KEY_TYPE_STRING = 4;
+    private final String indexID;
+    private final boolean ignoreWhiteSpaceInStrings;
+    private final boolean caseSensitiveStrings;
+    private final List<Pattern> indexedFieldPatterns;
+    private final QueryFilterVisitor<ConditionResult, JsonValue, JsonPointer> matcher = new Matcher();
+    private final List<? extends Indexer> indexers = Collections.singletonList(new IndexerImpl());
+
+    JsonQueryEqualityMatchingRuleImpl(final String indexID, Options options) {
+        this.indexID = indexID;
+        this.ignoreWhiteSpaceInStrings = options.get(IGNORE_WHITE_SPACE);
+        this.caseSensitiveStrings = options.get(CASE_SENSITIVE_STRINGS);
+        this.indexedFieldPatterns = compileWildCardPatterns(options.get(INDEXED_FIELD_PATTERNS));
+    }
+
+    private static List<Pattern> compileWildCardPatterns(final Collection<String> wildCardPatterns) {
+        final List<Pattern> regexes = new ArrayList<>();
+        for (final String wildCardPattern : wildCardPatterns) {
+            regexes.add(compileWildCardPattern(wildCardPattern));
+        }
+        return regexes;
+    }
+
+    /**
+     * Compiles a wild-card pattern into a regex taking care to normalize percent encoded characters, etc. This
+     * method is package private for testing.
+     */
+    static Pattern compileWildCardPattern(final String wildCardPattern) {
+        // Make the pattern easier to parse: replace multi-char sequences with a single char in order to avoid
+        // having to maintain state during subsequent parsing phase.
+        final char slashStarStar = '\u0000';
+        final char starStar = '\u0001';
+        final char star = '\u0002';
+        final String normalizedPattern = new JsonPointer(wildCardPattern).toString()
+                                                                         .replaceAll("/\\*\\*", "" + slashStarStar)
+                                                                         .replaceAll("\\*\\*", "" + starStar)
+                                                                         .replaceAll("\\*", "" + star);
+        final StringBuilder builder = new StringBuilder();
+        int elementStart = 0;
+        for (int i = 0; i < normalizedPattern.length(); i++) {
+            final char c = normalizedPattern.charAt(i);
+            if (c <= star) {
+                if (elementStart < i) {
+                    // Escape and add literal substring.
+                    builder.append(Pattern.quote(normalizedPattern.substring(elementStart, i)));
+                }
+                switch (c) {
+                case slashStarStar:
+                    builder.append("(/.*)?");
+                    break;
+                case starStar:
+                    builder.append(".*");
+                    break;
+                case star:
+                    builder.append("[^/]*");
+                    break;
+                }
+                elementStart = i + 1;
+            }
+        }
+        if (elementStart < normalizedPattern.length()) {
+            // Escape and add remaining literal substring.
+            builder.append(Pattern.quote(normalizedPattern.substring(elementStart)));
+        }
+        return Pattern.compile(builder.toString());
+    }
+
+    @Override
+    public Assertion getAssertion(final Schema schema, final ByteSequence assertionValue) throws DecodeException {
+        final QueryFilter<JsonPointer> queryFilter;
+        try {
+            queryFilter = QueryFilters.parse(assertionValue.toString());
+        } catch (Exception e) {
+            throw DecodeException.error(ERR_JSON_QUERY_PARSE_ERROR.get(assertionValue));
+        }
+
+        return new Assertion() {
+            @Override
+            public ConditionResult matches(final ByteSequence normalizedAttributeValue) {
+                try (final InputStream inputStream = normalizedAttributeValue.asReader().asInputStream()) {
+                    final Object object = LENIENT.getObjectMapper().readValue(inputStream, Object.class);
+                    final JsonValue jsonValue = new JsonValue(object);
+                    return queryFilter.accept(matcher, jsonValue);
+                } catch (IOException e) {
+                    // It may be that syntax validation was disabled when the attribute was created.
+                    return ConditionResult.FALSE;
+                }
+            }
+
+            @Override
+            public <T> T createIndexQuery(final IndexQueryFactory<T> factory) throws DecodeException {
+                return queryFilter.accept(new IndexQueryBuilder<T>(), factory);
+            }
+        };
+    }
+
+    @Override
+    public Assertion getSubstringAssertion(final Schema schema, final ByteSequence subInitial,
+                                           final List<? extends ByteSequence> subAnyElements,
+                                           final ByteSequence subFinal) throws DecodeException {
+        return UNDEFINED_ASSERTION;
+    }
+
+    @Override
+    public Assertion getGreaterOrEqualAssertion(final Schema schema, final ByteSequence value) throws DecodeException {
+        return UNDEFINED_ASSERTION;
+    }
+
+    @Override
+    public Assertion getLessOrEqualAssertion(final Schema schema, final ByteSequence value) throws DecodeException {
+        return UNDEFINED_ASSERTION;
+    }
+
+    @Override
+    public ByteString normalizeAttributeValue(final Schema schema, final ByteSequence value) throws DecodeException {
+        // The normalized representation is still valid JSON so that it can be reparsed during assertion matching.
+        try (final InputStream inputStream = value.asReader().asInputStream();
+             final JsonParser parser = LENIENT.getJsonFactory().createParser(inputStream)) {
+            JsonToken jsonToken = parser.nextToken();
+            if (jsonToken == null) {
+                throw DecodeException.error(ERR_JSON_EMPTY_CONTENT.get());
+            }
+
+            final ByteStringBuilder normalizedValue = new ByteStringBuilder(value.length());
+            normalizeJsonValue(parser, jsonToken, normalizedValue);
+            if (parser.nextToken() != null) {
+                throw DecodeException.error(ERR_JSON_TRAILING_CONTENT.get());
+            }
+            return normalizedValue.toByteString();
+        } catch (DecodeException e) {
+            throw e;
+        } catch (JsonProcessingException e) {
+            throw DecodeException.error(ERR_JSON_PARSE_ERROR.get(e.getLocation().getLineNr(),
+                                                                 e.getLocation().getColumnNr(),
+                                                                 e.getOriginalMessage()));
+        } catch (IOException e) {
+            throw DecodeException.error(ERR_JSON_IO_ERROR.get(e.getMessage()));
+        }
+    }
+
+    private void normalizeJsonValue(final JsonParser parser, JsonToken jsonToken, final ByteStringBuilder builder)
+            throws IOException {
+        switch (jsonToken) {
+        case START_OBJECT:
+            final TreeMap<String, ByteSequence> normalizedObject = new TreeMap<>();
+            while (parser.nextToken() != END_OBJECT) {
+                final String key = parser.getCurrentName();
+                final ByteStringBuilder value = new ByteStringBuilder();
+                normalizeJsonValue(parser, parser.nextToken(), value);
+                normalizedObject.put(key, value);
+            }
+            builder.appendByte('{');
+            boolean isFirstField = true;
+            for (Map.Entry<String, ByteSequence> keyValuePair : normalizedObject.entrySet()) {
+                if (!isFirstField) {
+                    builder.appendByte(',');
+                }
+                builder.appendByte('"');
+                builder.appendUtf8(keyValuePair.getKey());
+                builder.appendByte('"');
+                builder.appendByte(':');
+                builder.appendBytes(keyValuePair.getValue());
+                isFirstField = false;
+            }
+            builder.appendByte('}');
+            break;
+        case START_ARRAY:
+            builder.appendByte('[');
+            boolean isFirstElement = true;
+            while ((jsonToken = parser.nextToken()) != END_ARRAY) {
+                if (!isFirstElement) {
+                    builder.appendByte(',');
+                }
+                normalizeJsonValue(parser, jsonToken, builder);
+                isFirstElement = false;
+            }
+            builder.appendByte(']');
+            break;
+        case VALUE_STRING:
+            builder.appendByte('"');
+            builder.appendUtf8(normalizeString(parser.getText()));
+            builder.appendByte('"');
+            break;
+        case VALUE_NUMBER_INT:
+        case VALUE_NUMBER_FLOAT:
+            builder.appendUtf8(parser.getNumberValue().toString());
+            break;
+        case VALUE_TRUE:
+        case VALUE_FALSE:
+        case VALUE_NULL:
+            builder.appendUtf8(parser.getText());
+            break;
+        case END_OBJECT:
+        case END_ARRAY:
+        case FIELD_NAME:
+        case NOT_AVAILABLE:
+        case VALUE_EMBEDDED_OBJECT:
+            // Should not happen.
+            throw new IllegalStateException();
+        }
+    }
+
+    /** Normalize strings in a similar manner to LDAP's directory string matching rules. */
+    private String normalizeString(final String string) {
+        final StringBuilder builder = new StringBuilder(string.length());
+        prepareUnicode(builder, ByteString.valueOfUtf8(string), ignoreWhiteSpaceInStrings, !caseSensitiveStrings);
+        if (builder.length() == 0 && string.length() > 0) {
+            return " ";
+        }
+        return builder.toString();
+    }
+
+    @Override
+    public Collection<? extends Indexer> createIndexers(final IndexingOptions options) {
+        return indexers;
+    }
+
+    private class IndexerImpl implements Indexer {
+        @Override
+        public String getIndexID() {
+            return indexID;
+        }
+
+        @Override
+        public void createKeys(final Schema schema, final ByteSequence value, final Collection<ByteString> keys)
+                throws DecodeException {
+            try (final InputStream inputStream = value.asReader().asInputStream();
+                 final JsonParser parser = LENIENT.getJsonFactory().createParser(inputStream)) {
+                JsonToken jsonToken = parser.nextToken();
+                if (jsonToken == null) {
+                    throw DecodeException.error(ERR_JSON_EMPTY_CONTENT.get());
+                }
+
+                JsonPointer parentJsonPointer = new JsonPointer();
+                JsonPointer jsonPointer = new JsonPointer();
+                String normalizedJsonPointer = normalizeJsonPointer(jsonPointer);
+                final ByteStringBuilder builder = new ByteStringBuilder();
+                int depth = 0;
+                do {
+                    switch (jsonToken) {
+                    case START_OBJECT:
+                        parentJsonPointer = jsonPointer;
+                        depth++;
+                        break;
+                    case START_ARRAY:
+                        // Ignore array indices and instead treat elements as if they were multiple values for the
+                        // current pointer.
+                        depth++;
+                        break;
+                    case END_OBJECT:
+                        jsonPointer = parentJsonPointer;
+                        normalizedJsonPointer = normalizeJsonPointer(jsonPointer);
+                        parentJsonPointer = parentJsonPointer.parent();
+                        depth--;
+                        break;
+                    case END_ARRAY:
+                        depth--;
+                        break;
+                    case FIELD_NAME:
+                        // Normalize for the pathological case where a field name happens to be a number.
+                        jsonPointer = parentJsonPointer.child(parser.getCurrentName());
+                        normalizedJsonPointer = normalizeJsonPointer(jsonPointer);
+                        break;
+                    case VALUE_NULL:
+                        if (isFieldIndexed(normalizedJsonPointer)) {
+                            createFieldStartIndexKey(normalizedJsonPointer, builder);
+                            keys.add(createNullIndexKey(builder));
+                        }
+                        break;
+                    case VALUE_FALSE:
+                        if (isFieldIndexed(normalizedJsonPointer)) {
+                            createFieldStartIndexKey(normalizedJsonPointer, builder);
+                            keys.add(createBooleanIndexKey(builder, false));
+                        }
+                        break;
+                    case VALUE_TRUE:
+                        if (isFieldIndexed(normalizedJsonPointer)) {
+                            createFieldStartIndexKey(normalizedJsonPointer, builder);
+                            keys.add(createBooleanIndexKey(builder, true));
+                        }
+                        break;
+                    case VALUE_NUMBER_INT:
+                    case VALUE_NUMBER_FLOAT:
+                        if (isFieldIndexed(normalizedJsonPointer)) {
+                            createFieldStartIndexKey(normalizedJsonPointer, builder);
+                            keys.add(createNumberIndexKey(builder, parser.getDecimalValue()));
+                        }
+                        break;
+                    case VALUE_STRING:
+                        if (isFieldIndexed(normalizedJsonPointer)) {
+                            createFieldStartIndexKey(normalizedJsonPointer, builder);
+                            keys.add(createStringIndexKey(builder, parser.getText()));
+                        }
+                        break;
+                    case NOT_AVAILABLE:
+                    case VALUE_EMBEDDED_OBJECT:
+                        // Should not happen.
+                        throw new IllegalStateException();
+                    }
+                    builder.setLength(0);
+                    jsonToken = parser.nextToken();
+                } while (depth > 0);
+
+                if (parser.nextToken() != null) {
+                    throw DecodeException.error(ERR_JSON_TRAILING_CONTENT.get());
+                }
+            } catch (DecodeException e) {
+                throw e;
+            } catch (JsonProcessingException e) {
+                throw DecodeException.error(ERR_JSON_PARSE_ERROR.get(e.getLocation().getLineNr(),
+                                                                     e.getLocation().getColumnNr(),
+                                                                     e.getOriginalMessage()));
+            } catch (IOException e) {
+                throw DecodeException.error(ERR_JSON_IO_ERROR.get(e.getMessage()));
+            }
+        }
+
+        @Override
+        public String keyToHumanReadableString(final ByteSequence key) {
+            return key.toByteString().toASCIIString();
+        }
+    }
+
+    private boolean isFieldIndexed(final String normalizedJsonPointer) {
+        // Default behavior is that all fields are indexed.
+        if (indexedFieldPatterns.isEmpty()) {
+            return true;
+        }
+        // The field is indexed if it matches any of the configured patterns.
+        for (Pattern indexedFieldPattern : indexedFieldPatterns) {
+            if (indexedFieldPattern.matcher(normalizedJsonPointer).matches()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private ByteString createFieldStartIndexKey(final String normalizedJsonPointer) {
+        final ByteStringBuilder builder = new ByteStringBuilder(normalizedJsonPointer.length() + 1);
+        createFieldStartIndexKey(normalizedJsonPointer, builder);
+        return builder.toByteString();
+    }
+
+    private void createFieldStartIndexKey(final String normalizedJsonPointer, final ByteStringBuilder builder) {
+        builder.appendUtf8(normalizedJsonPointer);
+        builder.appendByte(KEY_FIELD_START);
+    }
+
+    private ByteString createFieldEndIndexKey(final String normalizedJsonPointer) {
+        final ByteStringBuilder builder = new ByteStringBuilder();
+        builder.appendUtf8(normalizedJsonPointer);
+        builder.appendByte(KEY_FIELD_END);
+        return builder.toByteString();
+    }
+
+    // Package private for testing.
+    ByteString createIndexKey(final String normalizedJsonPointer, final Object value) {
+        final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
+        return createIndexKey(fieldKey, value).toByteString();
+    }
+
+    private ByteSequence createIndexKey(final ByteString fieldKey, final Object value) {
+        final ByteStringBuilder builder = new ByteStringBuilder(fieldKey);
+        if (value == null) {
+            return createNullIndexKey(builder);
+        } else if (value instanceof Number) {
+            final Double doubleValue = ((Number) value).doubleValue();
+            return createNumberIndexKey(builder, BigDecimal.valueOf(doubleValue));
+        } else if (value instanceof Boolean) {
+            final Boolean booleanValue = (Boolean) value;
+            return createBooleanIndexKey(builder, booleanValue);
+        } else { // String or something unexpected in which case convert it to a string.
+            final String stringValue = normalizeString(value.toString());
+            return createStringIndexKey(builder, stringValue);
+        }
+    }
+
+    private ByteString createStringIndexKey(final ByteStringBuilder builder, final String string) {
+        builder.appendByte(KEY_TYPE_STRING);
+        builder.appendUtf8(normalizeString(string));
+        return builder.toByteString();
+    }
+
+    private ByteString createNumberIndexKey(final ByteStringBuilder builder, final BigDecimal number) {
+        // Re-use the integer matching rule in order to have a natural sort order. To do this we need
+        // to first convert floating point numbers to an integer. We multiply by 10^6 in order to
+        // preserve 6 decimal places of accuracy.
+        builder.appendByte(KEY_TYPE_NUMBER);
+        final ByteString micros = ByteString.valueOfObject(number.movePointRight(6).toBigInteger());
+        try {
+            builder.appendBytes(getIntegerMatchingRule().normalizeAttributeValue(micros));
+        } catch (DecodeException e) {
+            throw new RuntimeException(e); // Shouldn't happen since we know the value is valid.
+        }
+        return builder.toByteString();
+    }
+
+    private ByteString createBooleanIndexKey(final ByteStringBuilder builder, final boolean b) {
+        builder.appendByte(b ? KEY_TYPE_TRUE : KEY_TYPE_FALSE);
+        return builder.toByteString();
+    }
+
+    private ByteString createNullIndexKey(final ByteStringBuilder builder) {
+        builder.appendByte(KEY_TYPE_NULL);
+        return builder.toByteString();
+    }
+
+    /**
+     * We need to strip out numeric JSON pointer elements in order to cope with our lack of wild-card support when
+     * querying JSON arrays. Given the following JSON value:
+     * <pre>
+     *     {
+     *         "array": [ "value1", "value2", "value3" ],
+     *         "string": "value4",
+     *         "123": "legal!"
+     *     }
+     * </pre>
+     * We want to be able to perform queries against multi-valued fields without having to know the index of the
+     * element that we are looking for. For example, the wild-card filter {@code /array/* eq 'value2'} should match the
+     * above object because one of the array elements matches 'value2'. Unfortunately, there is no explicit wild-card
+     * support for JSON pointers, so we support it implicitly instead. Thus the filter {@code /array eq 'value2'}
+     * matches. We need to support explicit indexing though as well, so {@code /array/2 eq 'value2'} should match as
+     * well. This makes indexing a bit trickier, since we effectively need to index each value twice, once with the
+     * array index and once without.
+     * <p/>
+     * Indexes can return false positives, so a simple solution is to remove any JSON pointer tokens that look like
+     * array indices. This even works in the rare case where object keys are numbers. The above object yields the
+     * following keys:
+     * <pre>
+     *     array KEY_FIELD_START KEY_TYPE_STRING value1
+     *     array KEY_FIELD_START KEY_TYPE_STRING value2
+     *     array KEY_FIELD_START KEY_TYPE_STRING value3
+     *     string KEY_FIELD_START KEY_TYPE_STRING value4
+     *     &lt;empty&gt; KEY_FIELD_START KEY_TYPE_STRING legal!
+     * </pre>
+     */
+    private String normalizeJsonPointer(final JsonPointer jsonPointer) {
+        // Ensure that returned string has same encoding as JsonPointer.toString().
+        for (int i = 0; i < jsonPointer.size(); i++) {
+            final String token = jsonPointer.get(i);
+            if (isArrayIndex(token)) {
+                final ArrayList<String> tokens = new ArrayList<>(jsonPointer.size());
+                for (int j = 0; j < jsonPointer.size(); j++) {
+                    final String tokenj = jsonPointer.get(j);
+                    if (j == i || (j > i && isArrayIndex(tokenj))) {
+                        continue;
+                    }
+                    tokens.add(tokenj);
+                }
+                return new JsonPointer(tokens.toArray(new String[0])).toString();
+            }
+        }
+        return jsonPointer.toString();
+    }
+
+    private boolean isArrayIndex(final String token) {
+        final int length = token.length();
+        if (length == 0) {
+            return false;
+        }
+        for (int i = 0; i < length; i++) {
+            final char c = token.charAt(i);
+            if (c < '0' || c > '9') {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private class IndexQueryBuilder<T> implements QueryFilterVisitor<T, IndexQueryFactory<T>, JsonPointer> {
+        @Override
+        public T visitAndFilter(final IndexQueryFactory<T> indexQueryFactory,
+                                final List<QueryFilter<JsonPointer>> subFilters) {
+            final List<T> subQueries = new ArrayList<>(subFilters.size());
+            for (QueryFilter<JsonPointer> subFilter : subFilters) {
+                subQueries.add(subFilter.accept(this, indexQueryFactory));
+            }
+            return indexQueryFactory.createIntersectionQuery(subQueries);
+        }
+
+        @Override
+        public T visitBooleanLiteralFilter(final IndexQueryFactory<T> indexQueryFactory, final boolean value) {
+            return value ? indexQueryFactory.createMatchAllQuery()
+                         : indexQueryFactory.createUnionQuery(Collections.<T>emptySet());
+        }
+
+        @Override
+        public T visitContainsFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                     final Object valueAssertion) {
+            // Not supported yet, but we can at least narrow down the set of candidates to those entries containing
+            // the requested field.
+            return visitPresentFilter(indexQueryFactory, field);
+        }
+
+        @Override
+        public T visitEqualsFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                   final Object valueAssertion) {
+            final String normalizedJsonPointer = normalizeJsonPointer(field);
+            if (!isFieldIndexed(normalizedJsonPointer)) {
+                return indexQueryFactory.createMatchAllQuery();
+            }
+            final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
+            final ByteSequence key = createIndexKey(fieldKey, valueAssertion);
+            return indexQueryFactory.createExactMatchQuery(indexID, key);
+        }
+
+        @Override
+        public T visitExtendedMatchFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                          final String operator, final Object valueAssertion) {
+            // Not supported, so the filter does not match any entries.
+            return indexQueryFactory.createUnionQuery(Collections.<T>emptySet());
+        }
+
+        @Override
+        public T visitGreaterThanFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                        final Object valueAssertion) {
+            final String normalizedJsonPointer = normalizeJsonPointer(field);
+            if (!isFieldIndexed(normalizedJsonPointer)) {
+                return indexQueryFactory.createMatchAllQuery();
+            }
+            final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
+            final ByteSequence startKey = createIndexKey(fieldKey, valueAssertion);
+            final ByteString endKey = createFieldEndIndexKey(normalizedJsonPointer);
+            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, false, false);
+        }
+
+        @Override
+        public T visitGreaterThanOrEqualToFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                                 final Object valueAssertion) {
+            final String normalizedJsonPointer = normalizeJsonPointer(field);
+            if (!isFieldIndexed(normalizedJsonPointer)) {
+                return indexQueryFactory.createMatchAllQuery();
+            }
+            final ByteString fieldKey = createFieldStartIndexKey(normalizedJsonPointer);
+            final ByteSequence startKey = createIndexKey(fieldKey, valueAssertion);
+            final ByteString endKey = createFieldEndIndexKey(normalizedJsonPointer);
+            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, true, false);
+        }
+
+        @Override
+        public T visitLessThanFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                     final Object valueAssertion) {
+            final String normalizedJsonPointer = normalizeJsonPointer(field);
+            if (!isFieldIndexed(normalizedJsonPointer)) {
+                return indexQueryFactory.createMatchAllQuery();
+            }
+            final ByteString startKey = createFieldStartIndexKey(normalizedJsonPointer);
+            final ByteSequence endKey = createIndexKey(startKey, valueAssertion);
+            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, false, false);
+        }
+
+        @Override
+        public T visitLessThanOrEqualToFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                              final Object valueAssertion) {
+            final String normalizedJsonPointer = normalizeJsonPointer(field);
+            if (!isFieldIndexed(normalizedJsonPointer)) {
+                return indexQueryFactory.createMatchAllQuery();
+            }
+            final ByteString startKey = createFieldStartIndexKey(normalizedJsonPointer);
+            final ByteSequence endKey = createIndexKey(startKey, valueAssertion);
+            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, false, true);
+        }
+
+        @Override
+        public T visitNotFilter(final IndexQueryFactory<T> indexQueryFactory,
+                                final QueryFilter<JsonPointer> subFilter) {
+            // It's not possible to generate a query for a NOT filter so just consider all entries as candidates.
+            return indexQueryFactory.createMatchAllQuery();
+        }
+
+        @Override
+        public T visitOrFilter(final IndexQueryFactory<T> indexQueryFactory,
+                               final List<QueryFilter<JsonPointer>> subFilters) {
+            final List<T> subQueries = new ArrayList<>(subFilters.size());
+            for (QueryFilter<JsonPointer> subFilter : subFilters) {
+                subQueries.add(subFilter.accept(this, indexQueryFactory));
+            }
+            return indexQueryFactory.createUnionQuery(subQueries);
+        }
+
+        @Override
+        public T visitPresentFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field) {
+            final String normalizedJsonPointer = normalizeJsonPointer(field);
+            if (!isFieldIndexed(normalizedJsonPointer)) {
+                return indexQueryFactory.createMatchAllQuery();
+            }
+            final ByteString startKey = createFieldStartIndexKey(normalizedJsonPointer);
+            final ByteString endKey = createFieldEndIndexKey(normalizedJsonPointer);
+            return indexQueryFactory.createRangeMatchQuery(indexID, startKey, endKey, true, false);
+        }
+
+        @Override
+        public T visitStartsWithFilter(final IndexQueryFactory<T> indexQueryFactory, final JsonPointer field,
+                                       final Object valueAssertion) {
+            final String normalizedJsonPointer = normalizeJsonPointer(field);
+            if (!isFieldIndexed(normalizedJsonPointer)) {
+                return indexQueryFactory.createMatchAllQuery();
+            }
+            // These assertions make sense for string values, but don't make much sense for other primitive types.
+            if (valueAssertion instanceof String) {
+                return visitGreaterThanOrEqualToFilter(indexQueryFactory, field, valueAssertion);
+            }
+            // Best effort: 'true' starts with 'true' and '123' starts with '123', etc.
+            return visitEqualsFilter(indexQueryFactory, field, valueAssertion);
+        }
+    }
+
+    private final class Matcher implements QueryFilterVisitor<ConditionResult, JsonValue, JsonPointer> {
+        @Override
+        public ConditionResult visitAndFilter(final JsonValue jsonValue,
+                                              final List<QueryFilter<JsonPointer>> subFilters) {
+            ConditionResult r = ConditionResult.TRUE;
+            for (final QueryFilter<JsonPointer> subFilter : subFilters) {
+                final ConditionResult p = subFilter.accept(this, jsonValue);
+                if (p == ConditionResult.FALSE) {
+                    return p;
+                }
+                r = ConditionResult.and(r, p);
+            }
+            return r;
+        }
+
+        @Override
+        public ConditionResult visitBooleanLiteralFilter(final JsonValue jsonValue, final boolean value) {
+            return ConditionResult.valueOf(value);
+        }
+
+        @Override
+        public ConditionResult visitContainsFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                   final Object valueAssertion) {
+            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.CONTAINS);
+        }
+
+        @Override
+        public ConditionResult visitEqualsFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                 final Object valueAssertion) {
+            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.EQUALS);
+        }
+
+        @Override
+        public ConditionResult visitExtendedMatchFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                        final String operator, final Object valueAssertion) {
+            return ConditionResult.UNDEFINED; // Not supported.
+        }
+
+        @Override
+        public ConditionResult visitGreaterThanFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                      final Object valueAssertion) {
+            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.GREATER_THAN);
+        }
+
+        @Override
+        public ConditionResult visitGreaterThanOrEqualToFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                               final Object valueAssertion) {
+            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.GREATER_THAN_OR_EQUAL_TO);
+        }
+
+        @Override
+        public ConditionResult visitLessThanFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                   final Object valueAssertion) {
+            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.LESS_THAN);
+        }
+
+        @Override
+        public ConditionResult visitLessThanOrEqualToFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                            final Object valueAssertion) {
+            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.LESS_THAN_OR_EQUAL_TO);
+        }
+
+        @Override
+        public ConditionResult visitNotFilter(final JsonValue jsonValue, final QueryFilter<JsonPointer> subFilter) {
+            return ConditionResult.not(subFilter.accept(this, jsonValue));
+        }
+
+        @Override
+        public ConditionResult visitOrFilter(final JsonValue jsonValue,
+                                             final List<QueryFilter<JsonPointer>> subFilters) {
+            ConditionResult r = ConditionResult.FALSE;
+            for (final QueryFilter<JsonPointer> subFilter : subFilters) {
+                final ConditionResult p = subFilter.accept(this, jsonValue);
+                if (p == ConditionResult.TRUE) {
+                    return p;
+                }
+                r = ConditionResult.or(r, p);
+            }
+            return r;
+        }
+
+        @Override
+        public ConditionResult visitPresentFilter(final JsonValue jsonValue, final JsonPointer field) {
+            return ConditionResult.valueOf(jsonValue.get(field) != null);
+        }
+
+        @Override
+        public ConditionResult visitStartsWithFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                     final Object valueAssertion) {
+            return visitComparisonFilter(jsonValue, field, valueAssertion, FilterType.STARTS_WITH);
+        }
+
+        private ConditionResult visitComparisonFilter(final JsonValue jsonValue, final JsonPointer field,
+                                                      final Object valueAssertion, final FilterType equals) {
+            final JsonValue jsonValueField = jsonValue.get(field);
+            if (jsonValueField == null || jsonValueField.isMap()) {
+                return ConditionResult.FALSE;
+            }
+            if (jsonValueField.isList()) {
+                for (Object listElement : jsonValueField.asList()) {
+                    if (compare(equals, valueAssertion, listElement)) {
+                        return ConditionResult.TRUE;
+                    }
+                }
+                return ConditionResult.FALSE;
+            } else {
+                return ConditionResult.valueOf(compare(equals, valueAssertion, jsonValueField.getObject()));
+            }
+        }
+
+        private boolean compare(final FilterType type, final Object assertion, final Object value) {
+            if (assertion instanceof String && value instanceof String) {
+                final String stringAssertion = normalizeString((String) assertion);
+                final String stringValue = normalizeString((String) value);
+                switch (type) {
+                case CONTAINS:
+                    return stringValue.contains(stringAssertion);
+                case STARTS_WITH:
+                    return stringValue.startsWith(stringAssertion);
+                default:
+                    return compare0(type, stringAssertion, stringValue);
+                }
+            } else if (assertion instanceof Number && value instanceof Number) {
+                final Double doubleAssertion = ((Number) assertion).doubleValue();
+                final Double doubleValue = ((Number) value).doubleValue();
+                return compare0(type, doubleAssertion, doubleValue);
+            } else if (assertion instanceof Boolean && value instanceof Boolean) {
+                final Boolean booleanAssertion = (Boolean) assertion;
+                final Boolean booleanValue = (Boolean) value;
+                return compare0(type, booleanAssertion, booleanValue);
+            } else {
+                return false;
+            }
+        }
+
+        private <T extends Comparable<T>> boolean compare0(final FilterType type, final T assertion, final T value) {
+            switch (type) {
+            case EQUALS:
+            case CONTAINS:
+            case STARTS_WITH:
+                return value.equals(assertion);
+            case GREATER_THAN:
+                return value.compareTo(assertion) > 0;
+            case GREATER_THAN_OR_EQUAL_TO:
+                return value.compareTo(assertion) >= 0;
+            case LESS_THAN:
+                return value.compareTo(assertion) < 0;
+            case LESS_THAN_OR_EQUAL_TO:
+                return value.compareTo(assertion) <= 0;
+            }
+            return false;
+        }
+    }
+
+    private enum FilterType {
+        EQUALS,
+        CONTAINS,
+        GREATER_THAN,
+        GREATER_THAN_OR_EQUAL_TO,
+        LESS_THAN,
+        LESS_THAN_OR_EQUAL_TO,
+        STARTS_WITH
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java
new file mode 100644
index 0000000..81ef509
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImpl.java
@@ -0,0 +1,75 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
+
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_QUERY_PARSE_ERROR;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.SYNTAX_JSON_QUERY_DESCRIPTION;
+
+import org.forgerock.i18n.LocalizableMessageBuilder;
+import org.forgerock.json.resource.QueryFilters;
+import org.forgerock.opendj.ldap.ByteSequence;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.opendj.ldap.schema.SyntaxImpl;
+
+/** This class implements the JSON query attribute syntax. */
+final class JsonQuerySyntaxImpl implements SyntaxImpl {
+    @Override
+    public String getName() {
+        return SYNTAX_JSON_QUERY_DESCRIPTION;
+    }
+
+    @Override
+    public String getApproximateMatchingRule() {
+        return null;
+    }
+
+    @Override
+    public String getEqualityMatchingRule() {
+        return null;
+    }
+
+    @Override
+    public String getOrderingMatchingRule() {
+        return null;
+    }
+
+    @Override
+    public String getSubstringMatchingRule() {
+        return null;
+    }
+
+    @Override
+    public boolean isBEREncodingRequired() {
+        return false;
+    }
+
+    @Override
+    public boolean isHumanReadable() {
+        return true;
+    }
+
+    @Override
+    public boolean valueIsAcceptable(final Schema schema, final ByteSequence value,
+                                     final LocalizableMessageBuilder invalidReason) {
+        try {
+            QueryFilters.parse(value.toString());
+            return true;
+        } catch (Exception e) {
+            invalidReason.append(ERR_JSON_QUERY_PARSE_ERROR.get(value));
+            return false;
+        }
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java
new file mode 100644
index 0000000..50ee3b8
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSchema.java
@@ -0,0 +1,253 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
+
+import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_COMMENTS;
+import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_SINGLE_QUOTES;
+import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS;
+import static java.util.Collections.emptyList;
+import static org.forgerock.opendj.ldap.schema.Schema.getCoreSchema;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.STRICT;
+import static org.forgerock.util.Options.defaultOptions;
+
+import java.util.Collection;
+
+import org.forgerock.opendj.ldap.schema.MatchingRule;
+import org.forgerock.opendj.ldap.schema.MatchingRuleImpl;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.opendj.ldap.schema.SchemaBuilder;
+import org.forgerock.opendj.ldap.schema.Syntax;
+import org.forgerock.util.Option;
+import org.forgerock.util.Options;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Utility methods for obtaining JSON syntaxes and matching rules. See the package documentation for more detail.
+ */
+public final class JsonSchema {
+    /** JSON value validation policies. */
+    public enum ValidationPolicy {
+        /** JSON validation policy requiring strict conformance to RFC 7159. */
+        STRICT(new ObjectMapper()),
+        /**
+         * JSON validation policy requiring conformance to RFC 7159 with the following exceptions: 1) comments are
+         * allowed, 2) single quotes may be used instead of double quotes, and 3) unquoted control characters are
+         * allowed in strings.
+         */
+        LENIENT(new ObjectMapper().enable(ALLOW_COMMENTS)
+                                  .enable(ALLOW_SINGLE_QUOTES)
+                                  .enable(ALLOW_UNQUOTED_CONTROL_CHARS)),
+        /** JSON validation policy which does not perform any validation. */
+        DISABLED(null);
+        private final ObjectMapper objectMapper;
+
+        ValidationPolicy(final ObjectMapper objectMapper) {
+            this.objectMapper = objectMapper;
+        }
+
+        final JsonFactory getJsonFactory() {
+            return objectMapper.getFactory();
+        }
+
+        final ObjectMapper getObjectMapper() {
+            return objectMapper;
+        }
+    }
+
+    /**
+     * Schema option controlling syntax validation for JSON based attributes. By default this compatibility option
+     * is set to {@link ValidationPolicy#STRICT}.
+     */
+    public static final Option<ValidationPolicy> VALIDATION_POLICY = Option.withDefault(STRICT);
+    /**
+     * Matching rule option controlling whether JSON string comparisons should be case-sensitive. By default this
+     * compatibility option is set to {@code false} meaning that case will be ignored.
+     * <p>
+     * This option must be provided when constructing a JSON matching rule using {@link
+     * #newJsonQueryEqualityMatchingRuleImpl}, and cannot be overridden at the schema level.
+     */
+    public static final Option<Boolean> CASE_SENSITIVE_STRINGS = Option.withDefault(false);
+    /**
+     * Matching rule option controlling whether JSON string comparisons should ignore white-space. By default this
+     * compatibility option is set to {@code true} meaning that leading and trailing white-space will be ignored and
+     * intermediate white-space will be reduced to a single white-space character.
+     * <p>
+     * This option must be provided when constructing a JSON matching rule using {@link
+     * #newJsonQueryEqualityMatchingRuleImpl}, and cannot be overridden at the schema level.
+     */
+    public static final Option<Boolean> IGNORE_WHITE_SPACE = Option.withDefault(true);
+    /**
+     * Matching rule option controlling which JSON fields should be indexed by the matching rule. By default all
+     * fields will be indexed. To restrict the set of indexed fields specify a list whose values are wild-card
+     * patterns for matching against JSON pointers. Patterns are JSON pointers where "*" represents zero or more
+     * characters in a single path element, and "**" represents any number of path elements. For example:
+     *
+     * <table valign="top">
+     *     <tr><th>Pattern</th><th>Matches</th><th>Doesn't match</th></tr>
+     *     <tr><td>/aaa/bbb/ccc</td><td>/aaa/bbb/ccc</td><td>/aaa/bbb/ccc/ddd<br/>/aaa/bbb/cccc</td></tr>
+     *     <tr><td>/aaa/b&#x002A;/ccc</td><td>/aaa/bbb/ccc<br/>/aaa/bxx/ccc</td><td>/aaa/xxx/ccc<br/>/aaa/bbb</td></tr>
+     *     <tr><td>/aaa/&#x002A;&#x002A;/ddd</td><td>/aaa/ddd<br/>/aaa/xxx/yyy/ddd</td><td>/aaa/bbb/ccc</td></tr>
+     *     <tr><td>/aaa/&#x002A;&#x002A;</td><td>/aaa<br/>/aaa/bbb<br/>/aaa/bbb/ccc<br/></td><td>/aa</td></tr>
+     * </table>
+     */
+    @SuppressWarnings("unchecked")
+    public static final Option<Collection<String>> INDEXED_FIELD_PATTERNS =
+            (Option) Option.of(Collection.class, emptyList());
+    /** The OID of the JSON attribute syntax. */
+    static final String SYNTAX_JSON_OID = "1.3.6.1.4.1.36733.2.1.3.1";
+    /** The description of the JSON attribute syntax. */
+    static final String SYNTAX_JSON_DESCRIPTION = "Json";
+    /** The OID of the JSON query attribute syntax. */
+    static final String SYNTAX_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.3.2";
+    /** The description of the JSON query attribute syntax. */
+    static final String SYNTAX_JSON_QUERY_DESCRIPTION = "Json Query";
+    /** The OID of the case insensitive JSON query equality matching rule. */
+    static final String EMR_CASE_IGNORE_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.4.1";
+    /** The name of the case insensitive JSON query equality matching rule. */
+    static final String EMR_CASE_IGNORE_JSON_QUERY_NAME = "caseIgnoreJsonQueryMatch";
+    /** The OID of the case sensitive JSON query equality matching rule. */
+    static final String EMR_CASE_EXACT_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.4.2";
+    /** The name of the case sensitive JSON query equality matching rule. */
+    static final String EMR_CASE_EXACT_JSON_QUERY_NAME = "caseExactJsonQueryMatch";
+    private static final Syntax JSON_SYNTAX;
+    private static final Syntax JSON_QUERY_SYNTAX;
+    private static final MatchingRule CASE_IGNORE_JSON_QUERY_MATCHING_RULE;
+    private static final MatchingRule CASE_EXACT_JSON_QUERY_MATCHING_RULE;
+
+    static {
+        final Schema schema = addJsonSyntaxesAndMatchingRulesToSchema(new SchemaBuilder(getCoreSchema())).toSchema();
+        JSON_SYNTAX = schema.getSyntax(SYNTAX_JSON_OID);
+        JSON_QUERY_SYNTAX = schema.getSyntax(SYNTAX_JSON_QUERY_OID);
+        CASE_IGNORE_JSON_QUERY_MATCHING_RULE = schema.getMatchingRule(EMR_CASE_IGNORE_JSON_QUERY_OID);
+        CASE_EXACT_JSON_QUERY_MATCHING_RULE = schema.getMatchingRule(EMR_CASE_EXACT_JSON_QUERY_OID);
+    }
+
+    /**
+     * Returns the JSON attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.1. Attribute values of this syntax
+     * must be valid JSON. Use the {@link #VALIDATION_POLICY} schema option to control the degree of syntax
+     * enforcement. By default JSON attributes will support equality matching using the
+     * {@link #getCaseIgnoreJsonQueryMatchingRule() jsonQueryMatch} matching rule, although this may be overridden
+     * when defining individual attribute types.
+     *
+     * @return The JSON attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.1.
+     */
+    public static Syntax getJsonSyntax() {
+        return JSON_SYNTAX;
+    }
+
+    /**
+     * Returns the JSON Query attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.2. Attribute values of this
+     * syntax must be valid CREST JSON {@link org.forgerock.util.query.QueryFilter query filter} strings as
+     * defined in {@link org.forgerock.util.query.QueryFilterParser}.
+     *
+     * @return The JSON Query attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.2.
+     */
+    public static Syntax getJsonQuerySyntax() {
+        return JSON_QUERY_SYNTAX;
+    }
+
+    /**
+     * Returns the {@code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.1. The
+     * matching rule's assertion syntax is a {@link #getJsonQuerySyntax() CREST JSON query filter}. This matching
+     * rule will ignore case when comparing JSON strings as well as ignoring white-space. In addition, all JSON
+     * fields will be indexed if indexing is enabled.
+     *
+     * @return The @code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.1.
+     */
+    public static MatchingRule getCaseIgnoreJsonQueryMatchingRule() {
+        return CASE_IGNORE_JSON_QUERY_MATCHING_RULE;
+    }
+
+    /**
+     * Returns the {@code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.2. The
+     * matching rule's assertion syntax is a {@link #getJsonQuerySyntax() CREST JSON query filter}. This matching
+     * rule will ignore case when comparing JSON strings as well as ignoring white-space. In addition, all JSON
+     * fields will be indexed if indexing is enabled.
+     *
+     * @return The @code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.2.
+     */
+    public static MatchingRule getCaseExactJsonQueryMatchingRule() {
+        return CASE_EXACT_JSON_QUERY_MATCHING_RULE;
+    }
+
+    /**
+     * Creates a new custom JSON query equality matching rule implementation with the provided matching rule name and
+     * options. This method should be used when creating custom JSON matching rules whose behavior differs from
+     * {@link #getCaseIgnoreJsonQueryMatchingRule()}.
+     *
+     * @param matchingRuleName
+     *         The name of the matching rule. This will be used as the index ID in attribute indexes so it must not
+     *         collide with other indexes identifiers.
+     * @param options
+     *         The options controlling the behavior of the matching rule.
+     * @return The new custom JSON query equality matching rule implementation.
+     * @see #CASE_SENSITIVE_STRINGS
+     * @see #IGNORE_WHITE_SPACE
+     */
+    public static MatchingRuleImpl newJsonQueryEqualityMatchingRuleImpl(final String matchingRuleName,
+                                                                        final Options options) {
+        return new JsonQueryEqualityMatchingRuleImpl(matchingRuleName, options);
+    }
+
+    /**
+     * Adds the syntaxes and matching rules required by for JSON attribute support to the provided schema builder.
+     *
+     * @param builder
+     *         The schema builder to which the schema elements should be added.
+     * @return The schema builder.
+     */
+    public static SchemaBuilder addJsonSyntaxesAndMatchingRulesToSchema(final SchemaBuilder builder) {
+        builder.buildSyntax(SYNTAX_JSON_OID)
+               .description(SYNTAX_JSON_DESCRIPTION)
+               .implementation(new JsonSyntaxImpl())
+               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
+               .addToSchema();
+
+        builder.buildSyntax(SYNTAX_JSON_QUERY_OID)
+               .description(SYNTAX_JSON_QUERY_DESCRIPTION)
+               .implementation(new JsonQuerySyntaxImpl())
+               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
+               .addToSchema();
+
+        final JsonQueryEqualityMatchingRuleImpl caseIgnoreImpl = new JsonQueryEqualityMatchingRuleImpl(
+                EMR_CASE_IGNORE_JSON_QUERY_NAME,
+                defaultOptions().set(CASE_SENSITIVE_STRINGS, false).set(IGNORE_WHITE_SPACE, true));
+        builder.buildMatchingRule(EMR_CASE_IGNORE_JSON_QUERY_OID)
+               .names(EMR_CASE_IGNORE_JSON_QUERY_NAME)
+               .syntaxOID(SYNTAX_JSON_QUERY_OID)
+               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
+               .implementation(caseIgnoreImpl)
+               .addToSchema();
+
+        final JsonQueryEqualityMatchingRuleImpl caseExactImpl = new JsonQueryEqualityMatchingRuleImpl(
+                EMR_CASE_EXACT_JSON_QUERY_NAME,
+                defaultOptions().set(CASE_SENSITIVE_STRINGS, true).set(IGNORE_WHITE_SPACE, true));
+        builder.buildMatchingRule(EMR_CASE_EXACT_JSON_QUERY_OID)
+               .names(EMR_CASE_EXACT_JSON_QUERY_NAME)
+               .syntaxOID(SYNTAX_JSON_QUERY_OID)
+               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
+               .implementation(caseExactImpl)
+               .addToSchema();
+
+        return builder;
+    }
+
+    private JsonSchema() {
+        // Prevent instantiation.
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java
new file mode 100644
index 0000000..76cfd9c
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImpl.java
@@ -0,0 +1,123 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
+
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_EMPTY_CONTENT;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_IO_ERROR;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_PARSE_ERROR;
+import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_TRAILING_CONTENT;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.EMR_CASE_IGNORE_JSON_QUERY_OID;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.VALIDATION_POLICY;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.DISABLED;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.SYNTAX_JSON_DESCRIPTION;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.forgerock.i18n.LocalizableMessageBuilder;
+import org.forgerock.opendj.ldap.ByteSequence;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.opendj.ldap.schema.SyntaxImpl;
+import org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.JsonToken;
+
+/** This class implements the JSON attribute syntax. */
+final class JsonSyntaxImpl implements SyntaxImpl {
+    @Override
+    public String getName() {
+        return SYNTAX_JSON_DESCRIPTION;
+    }
+
+    @Override
+    public String getApproximateMatchingRule() {
+        return null;
+    }
+
+    @Override
+    public String getEqualityMatchingRule() {
+        return EMR_CASE_IGNORE_JSON_QUERY_OID;
+    }
+
+    @Override
+    public String getOrderingMatchingRule() {
+        return null;
+    }
+
+    @Override
+    public String getSubstringMatchingRule() {
+        return null;
+    }
+
+    @Override
+    public boolean isBEREncodingRequired() {
+        return false;
+    }
+
+    @Override
+    public boolean isHumanReadable() {
+        return true;
+    }
+
+    @Override
+    public boolean valueIsAcceptable(final Schema schema, final ByteSequence value,
+                                     final LocalizableMessageBuilder invalidReason) {
+        final ValidationPolicy validationPolicy = schema.getOption(VALIDATION_POLICY);
+        if (validationPolicy == DISABLED) {
+            return true;
+        }
+        try (final InputStream inputStream = value.asReader().asInputStream();
+             final JsonParser parser = validationPolicy.getJsonFactory().createParser(inputStream)) {
+            JsonToken jsonToken = parser.nextToken();
+            if (jsonToken == null) {
+                invalidReason.append(ERR_JSON_EMPTY_CONTENT.get());
+                return false;
+            }
+            int depth = 0;
+            do {
+                switch (jsonToken) {
+                case START_OBJECT:
+                case START_ARRAY:
+                    depth++;
+                    break;
+                case END_OBJECT:
+                case END_ARRAY:
+                    depth--;
+                    break;
+                default:
+                    // Skip.
+                }
+                jsonToken = parser.nextToken();
+            } while (depth > 0);
+
+            if (jsonToken != null) {
+                invalidReason.append(ERR_JSON_TRAILING_CONTENT.get());
+                return false;
+            }
+            return true;
+        } catch (JsonProcessingException e) {
+            invalidReason.append(ERR_JSON_PARSE_ERROR.get(e.getLocation().getLineNr(),
+                                                          e.getLocation().getColumnNr(),
+                                                          e.getOriginalMessage()));
+            return false;
+        } catch (IOException e) {
+            invalidReason.append(ERR_JSON_IO_ERROR.get(e.getMessage()));
+            return false;
+        }
+    }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java
new file mode 100755
index 0000000..3c347a0
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/schema/package-info.java
@@ -0,0 +1,95 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+
+/**
+ * This package contains LDAP schema syntaxes and matching rules for JSON based attributes.
+ * <p>
+ * There are two syntaxes, 'Json' and 'Json Query'.
+ * <pre>
+ * ( 1.3.6.1.4.1.36733.2.1.3.1 DESC 'Json' )
+ * ( 1.3.6.1.4.1.36733.2.1.3.2 DESC 'Json Query' )
+ * </pre>
+ * The first of these, {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getJsonSyntax() Json}, is an attribute
+ * syntax whose values must conform to the JSON syntax as defined in RFC 7159. The schema option {@link
+ * org.forgerock.opendj.rest2ldap.schema.JsonSchema#VALIDATION_POLICY} allows applications to relax the syntax
+ * enforcement. For example, to allow single quotes and comments set the following schema option:
+ * <pre>
+ * SchemaBuilder builder = ...;
+ * builder.setOption(JsonSchema.VALIDATION_POLICY, LENIENT);
+ * </pre>
+ * The second syntax, {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getJsonQuerySyntax() Json Query}, is an
+ * attribute syntax whose values are {@link org.forgerock.util.query.QueryFilterParser CREST query filters}. This syntax
+ * is also the assertion syntax used by the
+ * {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getCaseIgnoreJsonQueryMatchingRule()
+ * caseIgnoreJsonQueryMatch} and
+ * {@link org.forgerock.opendj.rest2ldap.schema.JsonSchema#getCaseExactJsonQueryMatchingRule() caseExactJsonQueryMatch}
+ * matching rules:
+ * <pre>
+ * ( 1.3.6.1.4.1.36733.2.1.4.1 NAME 'caseIgnoreJsonQueryMatch' SYNTAX 1.3.6.1.4.1.36733.2.1.3.2 )
+ * ( 1.3.6.1.4.1.36733.2.1.4.2 NAME 'caseExactJsonQueryMatch' SYNTAX 1.3.6.1.4.1.36733.2.1.3.2 )
+ * </pre>
+ * These syntaxes and matching rules are included by default with the OpenDJ server, but may be added to application
+ * code as follows:
+ * <pre>
+ * SchemaBuilder builder = ...;
+ * JsonSchema.addJsonSyntaxesAndMatchingRulesToSchema(schemaBuilder);
+ * </pre>
+ * <p>
+ * <b>Trying it out against OpenDJ server</b>
+ * <p>
+ * After install OpenDJ server add the following schema definition to config/schema/99-user.ldif:
+ * <pre>
+ * dn: cn=schema
+ * objectClass: top
+ * objectClass: ldapSubentry
+ * objectClass: subschema
+ * attributeTypes: ( 1.3.6.1.4.1.36733.2.1.1.999 NAME 'json'
+ *   SYNTAX 1.3.6.1.4.1.36733.2.1.3.1 EQUALITY caseIgnoreJsonQueryMatch SINGLE-VALUE )
+ * objectClasses: (1.3.6.1.4.1.36733.2.1.2.999 NAME 'jsonObject' SUP top
+ *   MUST (cn $ json ) )
+ * </pre>
+ * Start the server and then add the following entries:
+ * <pre>
+ * <b>path/to/opendj$ ./bin/ldapmodify -a -h localhost -p 1389 -D cn=directory\ manager -w password</b>
+ * dn: cn=bjensen,ou=people,dc=example,dc=com
+ * objectClass: top
+ * objectClass: jsonObject
+ * cn: bjensen
+ * json: { "_id":"bjensen", "_rev":"123", "name": { "first": "Babs", "surname": "Jensen" }, "age": 65, "roles": [
+ *   "sales", "admin" ] }
+ *
+ * dn: cn=scarter,ou=people,dc=example,dc=com
+ * objectClass: top
+ * objectClass: jsonObject
+ * cn: scarter
+ * json: { "_id":"scarter", "_rev":"456", "name": { "first": "Sam", "surname": "Carter" }, "age": 48, "roles": [
+ *   "manager", "eng" ] }
+ * </pre>
+ * A finally perform some searches:
+ * <pre>
+ * <b>path/to/opendj$ ./bin/ldapsearch -h localhost -p 1389 -D cn=directory\ manager -w password \
+ *   -b ou=people,dc=example,dc=com "(json=age lt 60 and name/first sw 's')"</b>
+ * dn: cn=scarter,ou=people,dc=example,dc=com
+ * objectClass: jsonObject
+ * objectClass: top
+ * cn: scarter
+ * json: { "_id":"scarter", "_rev":"456", "name": { "first": "Sam", "surname": "Car
+ *   ter" }, "age": 48, "roles": [ "manager", "eng" ] }
+ * </pre>
+ * The JSON query matching rules support indexing which can be enabled using dsconfig against the appropriate
+ * attribute index.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
diff --git a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
index 4264584..225bf31 100644
--- a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
+++ b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
@@ -140,3 +140,9 @@
 ERR_PASSWORD_RESET_USER_AUTHENTICATED_83=Passwords can only be reset by authenticated users
 ERR_BAD_API_RESOURCE_VERSION_84=The requested resource API version '%s' is unsupported. This endpoint only supports \
   the following resource API version(s): %s
+ERR_JSON_PARSE_ERROR_85=The value could not be parsed as valid JSON because a syntax error was detected on line %d column %d: %s
+ERR_JSON_IO_ERROR_86=The value could not be parsed as valid JSON: %s
+ERR_JSON_TRAILING_CONTENT_87=The value could not be parsed as valid JSON because it contains trailing content after the JSON value
+ERR_JSON_EMPTY_CONTENT_88=The value could not be parsed as valid JSON because it is empty
+ERR_JSON_QUERY_PARSE_ERROR_89=The value '%s' could not be parsed as a valid JSON query filter
+
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java
new file mode 100644
index 0000000..494a5ed
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQueryEqualityMatchingRuleImplTest.java
@@ -0,0 +1,574 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.forgerock.opendj.ldap.Attributes.singletonAttribute;
+import static org.forgerock.opendj.ldap.ConditionResult.FALSE;
+import static org.forgerock.opendj.ldap.ConditionResult.TRUE;
+import static org.forgerock.opendj.ldap.ConditionResult.UNDEFINED;
+import static org.forgerock.opendj.ldap.ConditionResult.or;
+import static org.forgerock.opendj.ldap.schema.Schema.getDefaultSchema;
+import static org.forgerock.opendj.rest2ldap.schema.JsonQueryEqualityMatchingRuleImpl.compileWildCardPattern;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.*;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.LENIENT;
+import static org.forgerock.util.Options.defaultOptions;
+import static org.mockito.Mockito.mock;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Pattern;
+
+import org.forgerock.json.JsonPointer;
+import org.forgerock.opendj.ldap.Assertion;
+import org.forgerock.opendj.ldap.AttributeDescription;
+import org.forgerock.opendj.ldap.ByteSequence;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.ConditionResult;
+import org.forgerock.opendj.ldap.DecodeException;
+import org.forgerock.opendj.ldap.Entry;
+import org.forgerock.opendj.ldap.Filter;
+import org.forgerock.opendj.ldap.LinkedHashMapEntry;
+import org.forgerock.opendj.ldap.Matcher;
+import org.forgerock.opendj.ldap.schema.MatchingRule;
+import org.forgerock.opendj.ldap.schema.Schema;
+import org.forgerock.opendj.ldap.schema.SchemaBuilder;
+import org.forgerock.opendj.ldap.spi.IndexQueryFactory;
+import org.forgerock.opendj.ldap.spi.Indexer;
+import org.forgerock.opendj.ldap.spi.IndexingOptions;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.forgerock.util.Options;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+@SuppressWarnings("javadoc")
+@Test
+public class JsonQueryEqualityMatchingRuleImplTest extends ForgeRockTestCase {
+    private final MatchingRule matchingRule = JsonSchema.getCaseIgnoreJsonQueryMatchingRule();
+    private final JsonQueryEqualityMatchingRuleImpl matchingRuleImpl =
+            new JsonQueryEqualityMatchingRuleImpl(EMR_CASE_IGNORE_JSON_QUERY_NAME, defaultOptions());
+    // @formatter:off
+    private final Schema schema = addJsonSyntaxesAndMatchingRulesToSchema(new SchemaBuilder(getDefaultSchema()))
+            .setOption(VALIDATION_POLICY, LENIENT)
+            .addAttributeType("( 9.9.9 NAME 'json' "
+                                      + "EQUALITY caseIgnoreJsonQueryMatch "
+                                      + "SYNTAX " + SYNTAX_JSON_OID + " )", true)
+            .toSchema();
+    // @formatter:on
+
+    @DataProvider
+    public static Object[][] validJson() {
+        // @formatter:off
+        return new Object[][] {
+            { "null", "null" },
+            { "false", "false" },
+            { "true", "true" },
+            { "123", "123" },
+            { "123.456", "123.456" },
+            { "'string'", "'string'" },
+            { "   '  HeLlo  WoRlD  '   ", "'hello world'" },
+            { "[]", "[]" },
+            { " [ 1, 2, 3 ] ", "[1,2,3]" },
+            // Sort keys
+            { " { 'c' : 1, 'a' : 2, 'b' : 3 } ", "{'a':2,'b':3,'c':1}" },
+            { "{'a':1,'A':2}", "{'A':2,'a':1}" },
+            // Case-sensitive keys
+            { "{'a':1,'a':2}", "{'a':2}" },
+            // Filter duplicate keys
+            // Nested objects
+            { "{'c':3,'b':[1,'One',1],'a':'XYZ'}", "{'a':'xyz','b':[1,'one',1],'c':3}" },
+            { "[1,2,{'c':3,'b':[1,'One',1],'a':'XYZ'}]", "[1,2,{'a':'xyz','b':[1,'one',1],'c':3}]" }
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "validJson")
+    public void testNormalizeAttributeValueWithValidJson(String json, String normalizedJson) throws Exception {
+        final ByteString expected = ByteString.valueOfUtf8(normalizedJson.replaceAll("'", "\""));
+        final ByteString normalizeAttributeValue = matchingRule.normalizeAttributeValue(ByteString.valueOfUtf8(json));
+        assertThat(normalizeAttributeValue).isEqualTo(expected);
+    }
+
+    @DataProvider
+    public static Object[][] invalidJson() {
+        // @formatter:off
+        return new Object[][] {
+            { "" },
+            { "x" },
+            { "'string" },
+            { "string'" },
+            { "'a' 'b'" },
+            { "000" },
+            { "3." },
+            { ".1" },
+            { "1 2 3" },
+            { "[1" },
+            { "1]" },
+            { "[1, 2," },
+            { "[1, 2, 3], 4]" },
+            { "[1, [2, 3, 4]" },
+            { "{'k1':'v1'" },
+            { "'k1':'v1'}" },
+            { "{'k1':'v1','k2':{'k3':'v3','k4':'v4','k5':'v5','k6':'v6'}" },
+            { "{'k1':'v1','k2':{'k3':'v3','k4':'v4','k5':'v5'}},'k6':'v6'}" }
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "invalidJson", expectedExceptions = DecodeException.class)
+    public void testNormalizeAttributeValueWithInvalidJson(String json) throws Exception {
+        matchingRule.normalizeAttributeValue(ByteString.valueOfUtf8(json));
+    }
+
+    @DataProvider
+    public Object[][] indexKeys() {
+        // @formatter:off
+
+        // json, keys...
+        return new Object[][] {
+            { "null",  keys(key("/", null)) },
+            { "false", keys(key("/", false)) },
+            { "true", keys(key("/", true)) },
+            { "123", keys(key("/", 123)) },
+            { "123.456", keys(key("/", 123.456)) },
+            { "'string'", keys(key("/", "string")) },
+            { "   '  HeLlo  WoRlD  '   ", keys(key("/", "hello world")) },
+            { "[]", keys() },
+            { " [ 1, 2, 3 ] ",
+              keys(key("/", 1),
+                   key("/", 2),
+                   key("/", 3)) },
+            { "{'ak1':'av1','ak2':{'bk1':'bv1','bk2':[1,2,3],'bk3':{'ck1':'cv1','ck2':'cv2'}},'ak3':'av3'}",
+              keys(key("/ak1", "av1"),
+                   key("/ak2/bk1", "bv1"),
+                   key("/ak2/bk2", 1),
+                   key("/ak2/bk2", 2),
+                   key("/ak2/bk2", 3),
+                   key("/ak2/bk3/ck1", "cv1"),
+                   key("/ak2/bk3/ck2", "cv2"),
+                   key("/ak3", "av3")),
+            }
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "indexKeys")
+    public void testCreateIndexers(String json, ByteString[] expectedKeys) throws Exception {
+        final Collection<? extends Indexer> indexers = matchingRule.createIndexers(mock(IndexingOptions.class));
+        assertThat(indexers).hasSize(1);
+        final Indexer indexer = indexers.iterator().next();
+        final ArrayList<ByteString> keys = new ArrayList<>();
+        indexer.createKeys(null, ByteString.valueOfUtf8(json), keys);
+        assertThat(keys).containsOnly(expectedKeys);
+    }
+
+    private ByteString[] keys(final ByteString... keys) {
+        return keys;
+    }
+
+    private ByteString key(final String jsonPointer, final Object value) {
+        return matchingRuleImpl.createIndexKey(jsonPointer, value);
+    }
+
+    /** JSON object that jsonQueryAssertions will match against. */
+    // @formatter:off
+    private final String json = "{"
+            + "'null':null,"
+            + "'true':true,"
+            + "'false':false,"
+            + "'intpos':123,"
+            + "'intneg':-123,"
+            + "'doublepos':12.3,"
+            + "'doubleneg':-12.3,"
+            + "'string':'  HELLO  world  ',"
+            + "'array':['One',['Two','Three'],{'key':'value'},'Four'],"
+            + "'object':{'nested':{'k1':'v1','k2':[1,2,3],'k3':'v3'},'tail':'tail','999':'999'}"
+            + "}";
+    // @formatter:on
+
+    @DataProvider
+    public static Object[][] jsonQueryAssertions() {
+        // @formatter:off
+        return new Object[][] {
+            // Test field addressing.
+            { "true", TRUE, false },
+            { "false", FALSE, false },
+            { "garbage", UNDEFINED, false },
+            { "/missing pr", FALSE, false },
+            { "/null pr", TRUE, false },
+            { "/missing eq 123", FALSE, false },
+            { "/null eq 123", FALSE, false },
+            { "/true eq true", TRUE, false },
+            { "/true eq false", FALSE, false },
+            { "/true eq 123", FALSE, false },
+            { "/false eq true", FALSE, false },
+            { "/false eq false", TRUE, false },
+            { "/intpos eq 123", TRUE, false },
+            { "/intpos eq -123", FALSE, false },
+            { "/intpos eq true", FALSE, false },
+            { "/intneg eq -123", TRUE, false },
+            { "/intneg eq 123", FALSE, false },
+            { "/intneg eq true", FALSE, false },
+            { "/doublepos eq 12.3", TRUE, false },
+            { "/doublepos eq -12.3", FALSE, false },
+            { "/doublepos eq true", FALSE, false },
+            { "/doubleneg eq -12.3", TRUE, false },
+            { "/doubleneg eq 12.3", FALSE, false },
+            { "/doubleneg eq true", FALSE, false },
+            { "/string eq 'hello world'", TRUE, false },
+            { "/string eq ' HELLO  WORLD '", TRUE, false },
+            { "/string eq 'hello mars'", FALSE, false },
+            { "/string eq 123", FALSE, false },
+            { "/array eq 'one'", TRUE, false },
+            { "/array eq 'two'", FALSE, true },
+            { "/array eq 'four'", TRUE, false },
+            { "/array/0 eq 'one'", TRUE, false },
+            { "/array/0 eq 'four'", FALSE, true },
+            { "/array/10 eq 'one'", FALSE, true },
+            { "/array/2/key eq 'value'", TRUE, false },
+            { "/object eq 'value'", FALSE, false },
+            { "/object/nested/k1 eq 'v1'", TRUE, false },
+            { "/object/tail eq 'tail'", TRUE, false },
+            { "/object/999 eq '999'", TRUE, false },
+            // Integer comparisons.
+            { "/intpos lt 1000", TRUE, false },
+            { "/intpos lt 123", FALSE, false },
+            { "/intpos lt -1000", FALSE, false },
+            { "/intneg lt 1000", TRUE, false },
+            { "/intneg lt -123", FALSE, false },
+            { "/intneg lt -1000", FALSE, false },
+            { "/intpos le 1000", TRUE, false },
+            { "/intpos le 123", TRUE, false },
+            { "/intpos le -1000", FALSE, false },
+            { "/intneg le 1000", TRUE, false },
+            { "/intneg le -123", TRUE, false },
+            { "/intneg le -1000", FALSE, false },
+            { "/intpos gt 1000", FALSE, false },
+            { "/intpos gt 123", FALSE, false },
+            { "/intpos gt -1000", TRUE, false },
+            { "/intneg gt 1000", FALSE, false },
+            { "/intneg gt -123", FALSE, false },
+            { "/intneg gt -1000", TRUE, false },
+            { "/intpos ge 1000", FALSE, false },
+            { "/intpos ge 123", TRUE, false },
+            { "/intpos ge -1000", TRUE, false },
+            { "/intneg ge 1000", FALSE, false },
+            { "/intneg ge -123", TRUE, false },
+            { "/intneg ge -1000", TRUE, false },
+            // Double comparisons.
+            { "/doublepos lt 100.0", TRUE, false },
+            { "/doublepos lt 12.3", FALSE, false },
+            { "/doublepos lt -100.0", FALSE, false },
+            { "/doubleneg lt 100.0", TRUE, false },
+            { "/doubleneg lt -12.3", FALSE, false },
+            { "/doubleneg lt -100.0", FALSE, false },
+            { "/doublepos le 100.0", TRUE, false },
+            { "/doublepos le 12.3", TRUE, false },
+            { "/doublepos le -100.0", FALSE, false },
+            { "/doubleneg le 100.0", TRUE, false },
+            { "/doubleneg le -12.3", TRUE, false },
+            { "/doubleneg le -100.0", FALSE, false },
+            { "/doublepos gt 100.0", FALSE, false },
+            { "/doublepos gt 12.3", FALSE, false },
+            { "/doublepos gt -100.0", TRUE, false },
+            { "/doubleneg gt 100.0", FALSE, false },
+            { "/doubleneg gt -12.3", FALSE, false },
+            { "/doubleneg gt -100.0", TRUE, false },
+            { "/doublepos ge 100.0", FALSE, false },
+            { "/doublepos ge 12.3", TRUE, false },
+            { "/doublepos ge -100.0", TRUE, false },
+            { "/doubleneg ge 100.0", FALSE, false },
+            { "/doubleneg ge -12.3", TRUE, false },
+            { "/doubleneg ge -100.0", TRUE, false },
+            // String comparisons.
+            { "/string lt 'zzz'", TRUE, false },
+            { "/string lt ' Hello  World '", FALSE, false },
+            { "/string lt 'aaa'", FALSE, false },
+            { "/string le 'zzz'", TRUE, false },
+            { "/string le ' Hello  World '", TRUE, false },
+            { "/string le 'aaa'", FALSE, false },
+            { "/string gt 'zzz'", FALSE, false },
+            { "/string gt ' Hello  World '", FALSE, false },
+            { "/string gt 'aaa'", TRUE, false },
+            { "/string ge 'zzz'", FALSE, false },
+            { "/string ge ' Hello  World '", TRUE, false },
+            { "/string ge 'aaa'", TRUE, false },
+            { "/string sw '  HELLO'", TRUE, false },
+            { "/string sw 'mars'", FALSE, false },
+            { "/string co '  LO  '", TRUE, false },
+            { "/string co 'mars'", FALSE, true },
+            // Test AND operator.
+            { "false and false", FALSE, false },
+            { "false and true", FALSE, false },
+            { "true and false", FALSE, false },
+            { "true and true", TRUE, false },
+            // Test OR operator.
+            { "false or false", FALSE, false },
+            { "false or true", TRUE, false },
+            { "true or false", TRUE, false },
+            { "true or true", TRUE, false },
+            // Test NOT operator.
+            // { "!false", TRUE, false }, // FIXME: bug in QueryFilterParser?
+            // { "!true", FALSE, true }, // FIXME: bug in QueryFilterParser?
+            { "!(false)", TRUE, true },
+            { "!(true)", FALSE, true },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "jsonQueryAssertions")
+    public void testAssertionMatching(final String query, final ConditionResult expected,
+                                      final boolean expectFalsePositives) throws Exception {
+        final Matcher matcher = Filter.equality("json", query).matcher(schema);
+        assertThat(matcher.matches(jsonEntry(schema))).isEqualTo(expected);
+    }
+
+    /**
+     * Tests index generation and querying. The previous test evaluates a JSON filter directly against a JSON value
+     * and checks that the returned filter result matches the expected result. This test, however, evaluates the
+     * filter against a pseudo index containing index keys for the JSON value. If the set of keys remaining at the
+     * end of the index query is non-empty then the "entry" is candidate. Remember that indexes are allowed to return
+     * false positives, so there is not a direct correlation between the expected filter result and the final key
+     * set's content: if the filter is expected to match then the key set must be non-empty if the filter is expected
+     * not to match then the key set should be empty, but MAY be non-empty in rare cases. The third test parameter
+     * "expectFalsePositives" indicates whether a false positive is expected.
+     */
+    @Test(dataProvider = "jsonQueryAssertions")
+    public void testAssertionCreateIndexQuery(final String query, final ConditionResult expected,
+                                              final boolean expectFalsePositives) throws Exception {
+        if (expected == UNDEFINED) {
+            try {
+                matchingRuleImpl.getAssertion(schema, ByteString.valueOfUtf8(query));
+                fail("Unexpectedly succeeded when parsing invalid assertion");
+            } catch (DecodeException e) {
+                // Expected.
+            }
+            return;
+        }
+        testAssertionIndexQuery(query, expected, expectFalsePositives, matchingRuleImpl, schema);
+    }
+
+    @DataProvider
+    public static Object[][] jsonQueryAssertionsPartialIndexing() {
+        // @formatter:off
+        return new Object[][] {
+            // Indexed.
+            { "/intpos eq 123", TRUE, false },
+            { "/intpos eq -123", FALSE, false },
+            // Not indexed.
+            { "/doublepos eq 12.3", TRUE, true },
+            { "/doublepos eq -12.3", FALSE, true },
+            // Indexed.
+            { "/string eq 'hello world'", TRUE, false },
+            { "/string eq 'hello mars'", FALSE, false },
+            // Indexed.
+            { "/array eq 'one'", TRUE, false },
+            { "/array eq 'two'", FALSE, true },
+            { "/array/0 eq 'one'", TRUE, false },
+            { "/array/0 eq 'four'", FALSE, true },
+            { "/array/10 eq 'one'", FALSE, true },
+            // Not indexed.
+            { "/array/2/key eq 'value'", TRUE, true },
+            { "/array/2/key eq 'nomatch'", FALSE, true },
+            // Indexed.
+            { "/object/nested/k1 eq 'v1'", TRUE, false },
+            { "/object/nested/k1 eq 'v2'", FALSE, false },
+            // Not indexed.
+            { "/object/tail eq 'tail'", TRUE, true },
+            { "/object/tail eq 'head'", FALSE, true },
+            { "/object/999 eq '999'", TRUE, true },
+            { "/object/999 eq '666'", FALSE, true },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "jsonQueryAssertionsPartialIndexing")
+    public void testAssertionCreateIndexQueryPartialIndexing(final String query, final ConditionResult expected,
+                                                     final boolean expectFalsePositives) throws Exception {
+        final Options options = defaultOptions()
+                .set(INDEXED_FIELD_PATTERNS, Arrays.asList("intpos", "/string", "/array", "/object/nested/**"));
+        final JsonQueryEqualityMatchingRuleImpl matchingRuleImpl =
+                new JsonQueryEqualityMatchingRuleImpl(EMR_CASE_IGNORE_JSON_QUERY_NAME, options);
+        final SchemaBuilder builder = new SchemaBuilder(getDefaultSchema());
+
+        addJsonSyntaxesAndMatchingRulesToSchema(builder);
+        builder.setOption(VALIDATION_POLICY, LENIENT)
+               .buildMatchingRule("9.9.9")
+               .names("partialIndexingTestMatch")
+               .syntaxOID(SYNTAX_JSON_QUERY_OID)
+               .implementation(matchingRuleImpl)
+               .addToSchema()
+               .addAttributeType("( 9.9.9 NAME 'json' EQUALITY partialIndexingTestMatch "
+                                         + "SYNTAX " + SYNTAX_JSON_OID + " )", true);
+        final Schema schema = builder.toSchema();
+        testAssertionIndexQuery(query, expected, expectFalsePositives, matchingRuleImpl, schema);
+    }
+
+    private void testAssertionIndexQuery(final String query, final ConditionResult expected,
+                                         final boolean expectFalsePositives,
+                                         final JsonQueryEqualityMatchingRuleImpl impl, final Schema schema)
+            throws DecodeException {
+        // The assertion is expected to be valid so create an index query. The generated query depends on the
+        // assertion, so there's not much that we can verify here.
+        final Indexer indexer = impl.createIndexers(null).iterator().next();
+        final TreeSet<ByteString> keys = new TreeSet<>();
+        indexer.createKeys(schema, ByteString.valueOfUtf8(json), keys);
+
+        final Assertion assertion = impl.getAssertion(schema, ByteString.valueOfUtf8(query));
+        final IndexQueryFactory<Set<ByteString>> factory = new MockIndexQueryFactory(keys);
+        final Set<ByteString> matchedKeys = assertion.createIndexQuery(factory);
+        final ConditionResult isPotentialMatch = ConditionResult.valueOf(!matchedKeys.isEmpty());
+
+        assertThat(isPotentialMatch).isEqualTo(or(expected, ConditionResult.valueOf(expectFalsePositives)));
+    }
+
+    private static class MockIndexQueryFactory implements IndexQueryFactory<Set<ByteString>> {
+        private final TreeSet<ByteString> keys;
+
+        private MockIndexQueryFactory(final TreeSet<ByteString> keys) {
+            this.keys = keys;
+        }
+
+        @Override
+        public Set<ByteString> createExactMatchQuery(final String indexID, final ByteSequence key) {
+            assertThat(indexID).isEqualTo(EMR_CASE_IGNORE_JSON_QUERY_NAME);
+            final ByteString keyByteString = key.toByteString();
+            if (keys.contains(keyByteString)) {
+                return Collections.singleton(keyByteString);
+            }
+            return Collections.emptySet();
+        }
+
+        @Override
+        public Set<ByteString> createMatchAllQuery() {
+            return keys;
+        }
+
+        @Override
+        public Set<ByteString> createRangeMatchQuery(final String indexID, final ByteSequence lower,
+                                                     final ByteSequence upper, final boolean lowerIncluded,
+                                                     final boolean upperIncluded) {
+            assertThat(indexID).isEqualTo(EMR_CASE_IGNORE_JSON_QUERY_NAME);
+            if (lower.isEmpty()) {
+                return keys.headSet(upper.toByteString(), upperIncluded);
+            } else if (upper.isEmpty()) {
+                return keys.tailSet(lower.toByteString(), lowerIncluded);
+            } else {
+                return keys.subSet(lower.toByteString(), lowerIncluded, upper.toByteString(), upperIncluded);
+            }
+        }
+
+        @Override
+        public Set<ByteString> createIntersectionQuery(final Collection<Set<ByteString>> subqueries) {
+            final TreeSet<ByteString> result = new TreeSet<>(keys);
+            for (Set<ByteString> subquery : subqueries) {
+                result.retainAll(subquery);
+            }
+            return result;
+        }
+
+        @Override
+        public Set<ByteString> createUnionQuery(final Collection<Set<ByteString>> subqueries) {
+            final TreeSet<ByteString> result = new TreeSet<>();
+            for (Set<ByteString> subquery : subqueries) {
+                result.addAll(subquery);
+            }
+            return result;
+        }
+
+        @Override
+        public IndexingOptions getIndexingOptions() {
+            return null;
+        }
+    }
+
+    @Test
+    public void testGetGreaterOrEqualAssertion() throws Exception {
+        assertThat(matchingRule.getGreaterOrEqualAssertion(null)).isSameAs(Assertion.UNDEFINED_ASSERTION);
+    }
+
+    @Test
+    public void testGetLessOrEqualAssertion() throws Exception {
+        assertThat(matchingRule.getLessOrEqualAssertion(null)).isSameAs(Assertion.UNDEFINED_ASSERTION);
+    }
+
+    @Test
+    public void testGetSubstringAssertion() throws Exception {
+        assertThat(matchingRule.getSubstringAssertion(null, null, null)).isSameAs(Assertion.UNDEFINED_ASSERTION);
+    }
+
+    @DataProvider
+    public static Object[][] patterns() {
+        // @formatter:off
+        return new Object[][] {
+            { "", "/", true },
+            { "/", "/", true },
+            { "/", "/a", false },
+            { "/a", "/a", true },
+            { "/a", "/aa", false},
+            { "/a", "/a/a", false},
+            { "/a/b", "/a/b", true },
+            { "/a/b", "/a/bb", false },
+            { "/a/*/c", "/a/b/c", true },
+            { "/a/*/c", "/a/bbb/c", true },
+            { "/a/*/c", "/a/b/b/c", false },
+            { "/a/*/c", "/a/c", false },
+            { "/a/**/c", "/a/b/c", true },
+            { "/a/**/c", "/a/bbb/c", true },
+            { "/a/**/c", "/a/b/b/c", true },
+            { "/a/**/c", "/a/b/b/c/c", true },
+            { "/a/**/c", "/a/b/b/c/cc", false },
+            { "/a/**/c", "/a/c", true },
+            { "/a/*", "/a", false },
+            { "/a/*", "/a/b", true },
+            { "/a/*", "/a/b/c", false },
+            { "/a/**", "/a", true },
+            { "/a/**", "/a/b", true },
+            { "/a/**", "/a/b", true },
+            { "/a/**", "/a/b/c", true },
+            { "/**/a", "/a", true },
+            { "/**/a", "/b", false },
+            { "/**/a", "/b/a", true },
+            { "/**/a", "/c/b/a", true },
+            { "/**/a", "/c/b/a/a", true },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "patterns")
+    public void testIndexedFieldPattens(String pattern, String field, boolean isMatchExpected) throws Exception {
+        final Pattern regex = compileWildCardPattern(pattern);
+        final String normalizedJsonPointer = new JsonPointer(field).toString();
+        assertThat(regex.matcher(normalizedJsonPointer).matches()).isEqualTo(isMatchExpected);
+    }
+
+    private Entry jsonEntry(final Schema schema) {
+        // @formatter:off
+        final Entry entry = new LinkedHashMapEntry("dn: cn=test",
+                                                   "objectClass: top",
+                                                   "objectClass: extensibleObject",
+                                                   "cn: test");
+        // @formatter:on
+        final AttributeDescription jsonAttributeDescription = AttributeDescription.valueOf("json", schema);
+        entry.addAttribute(singletonAttribute(jsonAttributeDescription, json));
+        return entry;
+    }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java
new file mode 100644
index 0000000..d822ebc
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonQuerySyntaxImplTest.java
@@ -0,0 +1,109 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.forgerock.i18n.LocalizableMessageBuilder;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.schema.Syntax;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+@SuppressWarnings("javadoc")
+@Test
+public class JsonQuerySyntaxImplTest extends ForgeRockTestCase {
+    private final Syntax syntax = JsonSchema.getJsonQuerySyntax();
+
+    @Test
+    public void testGetName() throws Exception {
+        assertThat(syntax.getName()).isEqualTo(JsonSchema.SYNTAX_JSON_QUERY_DESCRIPTION);
+    }
+
+    @Test
+    public void testGetApproximateMatchingRule() throws Exception {
+        assertThat(syntax.getApproximateMatchingRule()).isNull();
+    }
+
+    @Test
+    public void testGetEqualityMatchingRule() throws Exception {
+        assertThat(syntax.getEqualityMatchingRule()).isNull();
+    }
+
+    @Test
+    public void testGetOrderingMatchingRule() throws Exception {
+        assertThat(syntax.getOrderingMatchingRule()).isNull();
+    }
+
+    @Test
+    public void testGetSubstringMatchingRule() throws Exception {
+        assertThat(syntax.getSubstringMatchingRule()).isNull();
+    }
+
+    @Test
+    public void testIsBEREncodingRequired() throws Exception {
+        assertThat(syntax.isBEREncodingRequired()).isFalse();
+    }
+
+    @Test
+    public void testIsHumanReadable() throws Exception {
+        assertThat(syntax.isHumanReadable()).isTrue();
+    }
+
+    @DataProvider
+    public static Object[][] validJsonQuery() {
+        // There's no need to be exhaustive since the CREST unit tests should already provide sufficient coverage.
+
+        // @formatter:off
+        return new Object[][] {
+            { "true" },
+            { "_id eq 123" },
+            { "_id eq \"bjensen\"" },
+            { "_id eq \"bjensen\" and /a/b/c eq true" },
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "validJsonQuery")
+    public void testValueIsAcceptableWithValidJson(String value) throws Exception {
+        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
+        final boolean valueIsAcceptable =
+                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
+        assertThat(valueIsAcceptable).isTrue();
+        assertThat(localizableMessageBuilder).isEmpty();
+    }
+
+    @DataProvider
+    public static Object[][] invalidJsonQuery() {
+        // @formatter:off
+        return new Object[][] {
+            { "" },
+            { "bad value" },
+            { "/a%XXb eq 123" } // bad hex encoded char
+        };
+        // @formatter:on
+    }
+
+    @Test(dataProvider = "invalidJsonQuery")
+    public void testValueIsAcceptableWithInvalidValue(String value) throws Exception {
+        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
+        final boolean valueIsAcceptable =
+                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
+        assertThat(valueIsAcceptable).isFalse();
+        assertThat(localizableMessageBuilder).isNotEmpty();
+    }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java
new file mode 100644
index 0000000..bcc16a7
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/schema/JsonSyntaxImplTest.java
@@ -0,0 +1,102 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2016 ForgeRock AS.
+ */
+package org.forgerock.opendj.rest2ldap.schema;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.forgerock.opendj.ldap.schema.Schema.getDefaultSchema;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.VALIDATION_POLICY;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.LENIENT;
+import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.addJsonSyntaxesAndMatchingRulesToSchema;
+
+import org.forgerock.i18n.LocalizableMessageBuilder;
+import org.forgerock.opendj.ldap.ByteString;
+import org.forgerock.opendj.ldap.schema.SchemaBuilder;
+import org.forgerock.opendj.ldap.schema.Syntax;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+@SuppressWarnings("javadoc")
+@Test
+public class JsonSyntaxImplTest extends ForgeRockTestCase {
+    private final Syntax syntax = addJsonSyntaxesAndMatchingRulesToSchema(new SchemaBuilder(getDefaultSchema()))
+            .setOption(VALIDATION_POLICY, LENIENT)
+            .toSchema()
+            .getSyntax(JsonSchema.SYNTAX_JSON_OID);
+
+    @Test
+    public void testGetName() throws Exception {
+        assertThat(syntax.getName()).isEqualTo(JsonSchema.SYNTAX_JSON_DESCRIPTION);
+    }
+
+    @Test
+    public void testGetApproximateMatchingRule() throws Exception {
+        assertThat(syntax.getApproximateMatchingRule()).isNull();
+    }
+
+    @Test
+    public void testGetEqualityMatchingRule() throws Exception {
+        assertThat(syntax.getEqualityMatchingRule().getOID()).isEqualTo(JsonSchema.EMR_CASE_IGNORE_JSON_QUERY_OID);
+    }
+
+    @Test
+    public void testGetOrderingMatchingRule() throws Exception {
+        assertThat(syntax.getOrderingMatchingRule()).isNull();
+    }
+
+    @Test
+    public void testGetSubstringMatchingRule() throws Exception {
+        assertThat(syntax.getSubstringMatchingRule()).isNull();
+    }
+
+    @Test
+    public void testIsBEREncodingRequired() throws Exception {
+        assertThat(syntax.isBEREncodingRequired()).isFalse();
+    }
+
+    @Test
+    public void testIsHumanReadable() throws Exception {
+        assertThat(syntax.isHumanReadable()).isTrue();
+    }
+
+    @DataProvider
+    public static Object[][] validJson() {
+        return JsonQueryEqualityMatchingRuleImplTest.validJson();
+    }
+
+    @DataProvider
+    public static Object[][] invalidJson() {
+        return JsonQueryEqualityMatchingRuleImplTest.invalidJson();
+    }
+
+    @Test(dataProvider = "validJson")
+    public void testValueIsAcceptableWithValidJson(String value, String normalizedValue) throws Exception {
+        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
+        final boolean valueIsAcceptable =
+                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
+        assertThat(valueIsAcceptable).isTrue();
+        assertThat(localizableMessageBuilder).isEmpty();
+    }
+
+    @Test(dataProvider = "invalidJson")
+    public void testValueIsAcceptableWithInvalidJson(String value) throws Exception {
+        final LocalizableMessageBuilder localizableMessageBuilder = new LocalizableMessageBuilder();
+        final boolean valueIsAcceptable =
+                syntax.valueIsAcceptable(ByteString.valueOfUtf8(value), localizableMessageBuilder);
+        assertThat(valueIsAcceptable).isFalse();
+        assertThat(localizableMessageBuilder).isNotEmpty();
+    }
+}

--
Gitblit v1.10.0