diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ExpressionEvaluatingParameterBinder.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ExpressionEvaluatingParameterBinder.java deleted file mode 100644 index 0c46d4d8a..000000000 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ExpressionEvaluatingParameterBinder.java +++ /dev/null @@ -1,671 +0,0 @@ -/* - * Copyright 2015-2019 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.mongodb.repository.query; - -import lombok.EqualsAndHashCode; -import lombok.RequiredArgsConstructor; -import lombok.Value; -import lombok.experimental.UtilityClass; - -import java.io.StringWriter; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.UUID; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.bson.codecs.BinaryCodec; -import org.bson.codecs.Codec; -import org.bson.codecs.UuidCodec; -import org.bson.json.JsonWriter; -import org.bson.types.Binary; -import org.springframework.data.mongodb.CodecRegistryProvider; -import org.springframework.data.mongodb.core.query.SerializationUtils; -import org.springframework.data.mongodb.repository.query.StringBasedMongoQuery.ParameterBinding; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; -import org.springframework.expression.EvaluationContext; -import org.springframework.expression.Expression; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.Base64Utils; -import org.springframework.util.CollectionUtils; -import org.springframework.util.StringUtils; - -import com.mongodb.DBObject; -import com.mongodb.MongoClient; - -/** - * {@link ExpressionEvaluatingParameterBinder} allows to evaluate, convert and bind parameters to placeholders within a - * {@link String}. - * - * @author Christoph Strobl - * @author Thomas Darimont - * @author Oliver Gierke - * @author Mark Paluch - * @since 1.9 - */ -class ExpressionEvaluatingParameterBinder { - - private final SpelExpressionParser expressionParser; - private final QueryMethodEvaluationContextProvider evaluationContextProvider; - private final CodecRegistryProvider codecRegistryProvider; - - /** - * Creates new {@link ExpressionEvaluatingParameterBinder} - * - * @param expressionParser must not be {@literal null}. - * @param evaluationContextProvider must not be {@literal null}. - */ - public ExpressionEvaluatingParameterBinder(SpelExpressionParser expressionParser, - QueryMethodEvaluationContextProvider evaluationContextProvider) { - - Assert.notNull(expressionParser, "ExpressionParser must not be null!"); - Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null!"); - - this.expressionParser = expressionParser; - this.evaluationContextProvider = evaluationContextProvider; - this.codecRegistryProvider = () -> MongoClient.getDefaultCodecRegistry(); - } - - /** - * Bind values provided by {@link MongoParameterAccessor} to placeholders in {@literal raw} while considering - * potential conversions and parameter types. - * - * @param raw can be empty. - * @param accessor must not be {@literal null}. - * @param bindingContext must not be {@literal null}. - * @return {@literal null} if given {@code raw} value is empty. - */ - public String bind(String raw, MongoParameterAccessor accessor, BindingContext bindingContext) { - - if (!StringUtils.hasText(raw)) { - return raw; - } - - return replacePlaceholders(raw, accessor, bindingContext); - } - - /** - * Replaced the parameter placeholders with the actual parameter values from the given {@link ParameterBinding}s. - * - * @param input must not be {@literal null} or empty. - * @param accessor must not be {@literal null}. - * @param bindingContext must not be {@literal null}. - * @return - */ - private String replacePlaceholders(String input, MongoParameterAccessor accessor, BindingContext bindingContext) { - - if (!bindingContext.hasBindings()) { - return input; - } - - if (input.matches("^\\?\\d+$")) { - return getParameterValueForBinding(accessor, bindingContext.getParameters(), - bindingContext.getBindings().iterator().next()); - } - - Matcher matcher = createReplacementPattern(bindingContext.getBindings()).matcher(input); - StringBuffer buffer = new StringBuffer(); - - int parameterIndex = 0; - while (matcher.find()) { - - Placeholder placeholder = extractPlaceholder(parameterIndex++, matcher); - ParameterBinding binding = bindingContext.getBindingFor(placeholder); - String valueForBinding = getParameterValueForBinding(accessor, bindingContext.getParameters(), binding); - - // appendReplacement does not like unescaped $ sign and others, so we need to quote that stuff first - matcher.appendReplacement(buffer, Matcher.quoteReplacement(valueForBinding)); - if (StringUtils.hasText(placeholder.getSuffix())) { - buffer.append(placeholder.getSuffix()); - } - - if (placeholder.isQuoted()) { - postProcessQuotedBinding(buffer, valueForBinding, - !binding.isExpression() ? accessor.getBindableValue(binding.getParameterIndex()) : null, - binding.isExpression()); - } - } - - matcher.appendTail(buffer); - return buffer.toString(); - } - - /** - * Sanitize String binding by replacing single quoted values with double quotes which prevents potential single quotes - * contained in replacement to interfere with the Json parsing. Also take care of complex objects by removing the - * quotation entirely. - * - * @param buffer the {@link StringBuffer} to operate upon. - * @param valueForBinding the actual binding value. - * @param raw the raw binding value - * @param isExpression {@literal true} if the binding value results from a SpEL expression. - */ - private void postProcessQuotedBinding(StringBuffer buffer, String valueForBinding, @Nullable Object raw, - boolean isExpression) { - - int quotationMarkIndex = buffer.length() - valueForBinding.length() - 1; - char quotationMark = buffer.charAt(quotationMarkIndex); - - while (quotationMark != '\'' && quotationMark != '"') { - - quotationMarkIndex--; - - if (quotationMarkIndex < 0) { - throw new IllegalArgumentException("Could not find opening quotes for quoted parameter"); - } - - quotationMark = buffer.charAt(quotationMarkIndex); - } - - // remove quotation char before the complex object string - if (valueForBinding.startsWith("{") && (raw instanceof DBObject || isExpression)) { - - buffer.deleteCharAt(quotationMarkIndex); - - } else { - - if (isExpression) { - - buffer.deleteCharAt(quotationMarkIndex); - return; - } - - if (quotationMark == '\'') { - buffer.replace(quotationMarkIndex, quotationMarkIndex + 1, "\""); - } - - buffer.append("\""); - } - } - - /** - * Returns the serialized value to be used for the given {@link ParameterBinding}. - * - * @param accessor must not be {@literal null}. - * @param parameters - * @param binding must not be {@literal null}. - * @return - */ - @SuppressWarnings("unchecked") - private String getParameterValueForBinding(MongoParameterAccessor accessor, MongoParameters parameters, - ParameterBinding binding) { - - Object value = binding.isExpression() - ? evaluateExpression(binding.getExpression(), parameters, accessor.getValues()) - : accessor.getBindableValue(binding.getParameterIndex()); - - if (value instanceof String && binding.isQuoted()) { - - if (binding.isExpression() && ((String) value).startsWith("{")) { - return (String) value; - } - - String encodedValue = serialize(value); - return binding.isExpression() ? encodedValue : QuotedString.unquote(encodedValue); - } - - return EncodableValue.create(value).encode(codecRegistryProvider, binding.isQuoted()); - } - - /** - * Evaluates the given {@code expressionString}. - * - * @param expressionString must not be {@literal null} or empty. - * @param parameters must not be {@literal null}. - * @param parameterValues must not be {@literal null}. - * @return - */ - @Nullable - private Object evaluateExpression(String expressionString, MongoParameters parameters, Object[] parameterValues) { - - EvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(parameters, parameterValues); - Expression expression = expressionParser.parseExpression(expressionString); - - return expression.getValue(evaluationContext, Object.class); - } - - /** - * Creates a replacement {@link Pattern} for all {@link ParameterBinding#getParameter() binding parameters} including - * a potentially trailing quotation mark. - * - * @param bindings - * @return - */ - private Pattern createReplacementPattern(List bindings) { - - StringBuilder regex = new StringBuilder(); - - for (ParameterBinding binding : bindings) { - - regex.append("|"); - regex.append("(" + Pattern.quote(binding.getParameter()) + ")"); - regex.append("([\\w.]*"); - regex.append("(\\W?['\"]|\\w*')?)"); - } - - return Pattern.compile(regex.substring(1)); - } - - /** - * Extract the placeholder stripping any trailing trailing quotation mark that might have resulted from the - * {@link #createReplacementPattern(List) pattern} used. - * - * @param parameterIndex The actual parameter index. - * @param matcher The actual {@link Matcher}. - * @return - */ - private Placeholder extractPlaceholder(int parameterIndex, Matcher matcher) { - - String rawPlaceholder = matcher.group(parameterIndex * 3 + 1); - String suffix = matcher.group(parameterIndex * 3 + 2); - - if (!StringUtils.hasText(rawPlaceholder)) { - - rawPlaceholder = matcher.group(); - if (rawPlaceholder.matches(".*\\d$")) { - suffix = ""; - } else { - int index = rawPlaceholder.replaceAll("[^\\?0-9]*$", "").length() - 1; - if (index > 0 && rawPlaceholder.length() > index) { - suffix = rawPlaceholder.substring(index + 1); - } - } - if (QuotedString.endsWithQuote(rawPlaceholder)) { - rawPlaceholder = rawPlaceholder.substring(0, - rawPlaceholder.length() - (StringUtils.hasText(suffix) ? suffix.length() : 1)); - } - } - - if (StringUtils.hasText(suffix)) { - - boolean quoted = QuotedString.endsWithQuote(suffix); - - return Placeholder.of(parameterIndex, rawPlaceholder, quoted, - quoted ? QuotedString.unquoteSuffix(suffix) : suffix); - } - return Placeholder.of(parameterIndex, rawPlaceholder, false, null); - } - - /** - * @author Christoph Strobl - * @author Mark Paluch - * @since 1.9 - */ - static class BindingContext { - - final MongoParameters parameters; - final Map bindings; - - /** - * Creates new {@link BindingContext}. - * - * @param parameters - * @param bindings - */ - public BindingContext(MongoParameters parameters, List bindings) { - - this.parameters = parameters; - this.bindings = mapBindings(bindings); - } - - /** - * @return {@literal true} when list of bindings is not empty. - */ - boolean hasBindings() { - return !CollectionUtils.isEmpty(bindings); - } - - /** - * Get unmodifiable list of {@link ParameterBinding}s. - * - * @return never {@literal null}. - */ - public List getBindings() { - return new ArrayList(bindings.values()); - } - - /** - * Get the concrete {@link ParameterBinding} for a given {@literal placeholder}. - * - * @param placeholder must not be {@literal null}. - * @return - * @throws java.util.NoSuchElementException - * @since 1.10 - */ - ParameterBinding getBindingFor(Placeholder placeholder) { - - if (!bindings.containsKey(placeholder)) { - throw new NoSuchElementException(String.format("Could not to find binding for placeholder '%s'.", placeholder)); - } - - return bindings.get(placeholder); - } - - /** - * Get the associated {@link MongoParameters}. - * - * @return - */ - public MongoParameters getParameters() { - return parameters; - } - - private static Map mapBindings(List bindings) { - - Map map = new LinkedHashMap(bindings.size(), 1); - - int parameterIndex = 0; - for (ParameterBinding binding : bindings) { - map.put(Placeholder.of(parameterIndex++, binding.getParameter(), binding.isQuoted(), null), binding); - } - - return map; - } - } - - /** - * Encapsulates a quoted/unquoted parameter placeholder. - * - * @author Mark Paluch - * @since 1.9 - */ - @Value(staticConstructor = "of") - @EqualsAndHashCode(exclude = { "quoted", "suffix" }) - static class Placeholder { - - private int parameterIndex; - private final String parameter; - private final boolean quoted; - private final @Nullable String suffix; - - /* - * (non-Javadoc) - * @see java.lang.Object#toString() - */ - @Override - public String toString() { - return quoted ? String.format("'%s'", parameter + (suffix != null ? suffix : "")) - : parameter + (suffix != null ? suffix : ""); - } - - } - - /** - * Utility to handle quoted strings using single/double quotes. - * - * @author Mark Paluch - */ - @UtilityClass - static class QuotedString { - - /** - * @param string - * @return {@literal true} if {@literal string} ends with a single/double quote. - */ - static boolean endsWithQuote(String string) { - return string.endsWith("'") || string.endsWith("\""); - } - - /** - * Remove trailing quoting from {@literal quoted}. - * - * @param quoted - * @return {@literal quoted} with removed quotes. - */ - public static String unquoteSuffix(String quoted) { - return quoted.substring(0, quoted.length() - 1); - } - - /** - * Remove leading and trailing quoting from {@literal quoted}. - * - * @param quoted - * @return {@literal quoted} with removed quotes. - */ - public static String unquote(String quoted) { - return quoted.substring(1, quoted.length() - 1); - } - } - - /** - * Value object encapsulating a bindable value, that can be encoded to be represented as JSON (BSON). - * - * @author Mark Paluch - */ - abstract static class EncodableValue { - - /** - * Obtain a {@link EncodableValue} given {@code value}. - * - * @param value the value to encode, may be {@literal null}. - * @return the {@link EncodableValue} for {@code value}. - */ - @SuppressWarnings("unchecked") - public static EncodableValue create(@Nullable Object value) { - - if (value instanceof byte[]) { - return new BinaryValue((byte[]) value); - } - - if (value instanceof UUID) { - return new UuidValue((UUID) value); - } - - if (value instanceof Collection) { - - Collection collection = (Collection) value; - Class commonElement = CollectionUtils.findCommonElementType(collection); - - if (commonElement != null) { - - if (UUID.class.isAssignableFrom(commonElement)) { - return new UuidCollection((Collection) value); - } - - if (byte[].class.isAssignableFrom(commonElement)) { - return new BinaryCollectionValue((Collection) value); - } - } - } - - return new ObjectValue(value); - } - - /** - * Encode the encapsulated value. - * - * @param provider - * @param quoted - * @return - */ - public abstract String encode(CodecRegistryProvider provider, boolean quoted); - - /** - * Encode a {@code value} to JSON. - * - * @param provider - * @param value - * @param defaultCodec - * @param - * @return - */ - protected String encode(CodecRegistryProvider provider, V value, Supplier> defaultCodec) { - - StringWriter writer = new StringWriter(); - - doEncode(provider, writer, value, defaultCodec); - - return writer.toString(); - } - - /** - * Encode a {@link Collection} to JSON and potentially apply a {@link Function mapping function} before encoding. - * - * @param provider - * @param value - * @param mappingFunction - * @param defaultCodec - * @param Input value type. - * @param Target type. - * @return - */ - protected String encodeCollection(CodecRegistryProvider provider, Iterable value, - Function mappingFunction, Supplier> defaultCodec) { - - StringWriter writer = new StringWriter(); - - writer.append("["); - value.forEach(it -> { - - if (writer.getBuffer().length() > 1) { - writer.append(", "); - } - - doEncode(provider, writer, mappingFunction.apply(it), defaultCodec); - }); - - writer.append("]"); - writer.flush(); - - return writer.toString(); - } - - @SuppressWarnings("unchecked") - private void doEncode(CodecRegistryProvider provider, StringWriter writer, V value, - Supplier> defaultCodec) { - - Codec codec = provider.getCodecFor((Class) value.getClass()).orElseGet(defaultCodec); - - JsonWriter jsonWriter = new JsonWriter(writer); - codec.encode(jsonWriter, value, null); - jsonWriter.flush(); - } - } - - /** - * {@link EncodableValue} for {@code byte[]} to render to {@literal $binary}. - */ - @RequiredArgsConstructor - static class BinaryValue extends EncodableValue { - - private final byte[] value; - - /* - * (non-Javadoc) - * @see org.springframework.data.mongodb.repository.query.ExpressionEvaluatingParameterBinder.EncodableValue#encode(org.springframework.data.mongodb.CodecRegistryProvider, boolean) - */ - @Override - public String encode(CodecRegistryProvider provider, boolean quoted) { - - if (quoted) { - return Base64Utils.encodeToString(this.value); - } - - return encode(provider, new Binary(this.value), BinaryCodec::new); - } - } - - /** - * {@link EncodableValue} for {@link Collection} containing only {@code byte[]} items to render to a BSON list - * containing {@literal $binary}. - */ - @RequiredArgsConstructor - static class BinaryCollectionValue extends EncodableValue { - - private final Collection value; - - /* - * (non-Javadoc) - * @see org.springframework.data.mongodb.repository.query.ExpressionEvaluatingParameterBinder.EncodableValue#encode(org.springframework.data.mongodb.CodecRegistryProvider, boolean) - */ - @Override - public String encode(CodecRegistryProvider provider, boolean quoted) { - return encodeCollection(provider, this.value, Binary::new, BinaryCodec::new); - } - } - - /** - * {@link EncodableValue} for {@link UUID} to render to {@literal $binary}. - */ - @RequiredArgsConstructor - static class UuidValue extends EncodableValue { - - private final UUID value; - - /* - * (non-Javadoc) - * @see org.springframework.data.mongodb.repository.query.ExpressionEvaluatingParameterBinder.EncodableValue#encode(org.springframework.data.mongodb.CodecRegistryProvider, boolean) - */ - @Override - public String encode(CodecRegistryProvider provider, boolean quoted) { - - if (quoted) { - return this.value.toString(); - } - - return encode(provider, this.value, UuidCodec::new); - } - } - - /** - * {@link EncodableValue} for {@link Collection} containing only {@link UUID} items to render to a BSON list - * containing {@literal $binary}. - */ - @RequiredArgsConstructor - static class UuidCollection extends EncodableValue { - - private final Collection value; - - /* - * (non-Javadoc) - * @see org.springframework.data.mongodb.repository.query.ExpressionEvaluatingParameterBinder.EncodableValue#encode(org.springframework.data.mongodb.CodecRegistryProvider, boolean) - */ - @Override - public String encode(CodecRegistryProvider provider, boolean quoted) { - return encodeCollection(provider, this.value, Function.identity(), UuidCodec::new); - } - } - - /** - * Fallback-{@link EncodableValue} for {@link Object}-typed values. - */ - @RequiredArgsConstructor - static class ObjectValue extends EncodableValue { - - private final @Nullable Object value; - - /* - * (non-Javadoc) - * @see org.springframework.data.mongodb.repository.query.ExpressionEvaluatingParameterBinder.EncodableValue#encode(org.springframework.data.mongodb.CodecRegistryProvider, boolean) - */ - @Override - public String encode(CodecRegistryProvider provider, boolean quoted) { - return serialize(this.value); - } - } - - static String serialize(Object value) { - return SerializationUtils.serializeValue(value); - } -} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedMongoQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedMongoQuery.java index 7b988c08c..86b3861ae 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedMongoQuery.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedMongoQuery.java @@ -15,18 +15,15 @@ */ package org.springframework.data.mongodb.repository.query; -import java.util.ArrayList; -import java.util.List; - +import org.bson.Document; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.ReactiveMongoOperations; import org.springframework.data.mongodb.core.query.BasicQuery; import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.repository.query.ExpressionEvaluatingParameterBinder.BindingContext; -import org.springframework.data.mongodb.repository.query.StringBasedMongoQuery.ParameterBinding; -import org.springframework.data.mongodb.repository.query.StringBasedMongoQuery.ParameterBindingParser; +import org.springframework.data.mongodb.util.json.ParameterBindingContext; +import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.util.Assert; @@ -42,16 +39,17 @@ public class ReactiveStringBasedMongoQuery extends AbstractReactiveMongoQuery { private static final String COUNT_EXISTS_AND_DELETE = "Manually defined query for %s cannot be a count and exists or delete query at the same time!"; private static final Logger LOG = LoggerFactory.getLogger(ReactiveStringBasedMongoQuery.class); - private static final ParameterBindingParser BINDING_PARSER = ParameterBindingParser.INSTANCE; + private static final ParameterBindingDocumentCodec CODEC = new ParameterBindingDocumentCodec(); private final String query; private final String fieldSpec; + + private final SpelExpressionParser expressionParser; + private final QueryMethodEvaluationContextProvider evaluationContextProvider; + private final boolean isCountQuery; private final boolean isExistsQuery; private final boolean isDeleteQuery; - private final List queryParameterBindings; - private final List fieldSpecParameterBindings; - private final ExpressionEvaluatingParameterBinder parameterBinder; /** * Creates a new {@link ReactiveStringBasedMongoQuery} for the given {@link MongoQueryMethod} and @@ -85,13 +83,10 @@ public class ReactiveStringBasedMongoQuery extends AbstractReactiveMongoQuery { Assert.notNull(query, "Query must not be null!"); Assert.notNull(expressionParser, "SpelExpressionParser must not be null!"); - this.queryParameterBindings = new ArrayList(); - this.query = BINDING_PARSER.parseAndCollectParameterBindingsFromQueryIntoBindings(query, - this.queryParameterBindings); - - this.fieldSpecParameterBindings = new ArrayList(); - this.fieldSpec = BINDING_PARSER.parseAndCollectParameterBindingsFromQueryIntoBindings( - method.getFieldSpecification(), this.fieldSpecParameterBindings); + this.query = query; + this.expressionParser = expressionParser; + this.evaluationContextProvider = evaluationContextProvider; + this.fieldSpec = method.getFieldSpecification(); if (method.hasAnnotatedQuery()) { @@ -111,8 +106,6 @@ public class ReactiveStringBasedMongoQuery extends AbstractReactiveMongoQuery { this.isExistsQuery = false; this.isDeleteQuery = false; } - - this.parameterBinder = new ExpressionEvaluatingParameterBinder(expressionParser, evaluationContextProvider); } /* @@ -122,12 +115,13 @@ public class ReactiveStringBasedMongoQuery extends AbstractReactiveMongoQuery { @Override protected Query createQuery(ConvertingParameterAccessor accessor) { - String queryString = parameterBinder.bind(this.query, accessor, - new BindingContext(getQueryMethod().getParameters(), queryParameterBindings)); - String fieldsString = parameterBinder.bind(this.fieldSpec, accessor, - new BindingContext(getQueryMethod().getParameters(), fieldSpecParameterBindings)); + ParameterBindingContext bindingContext = new ParameterBindingContext((accessor::getBindableValue), expressionParser, + evaluationContextProvider.getEvaluationContext(getQueryMethod().getParameters(), accessor.getValues())); - Query query = new BasicQuery(queryString, fieldsString).with(accessor.getSort()); + Document queryObject = CODEC.decode(this.query, bindingContext); + Document fieldsObject = CODEC.decode(this.fieldSpec, bindingContext); + + Query query = new BasicQuery(queryObject, fieldsObject).with(accessor.getSort()); if (LOG.isDebugEnabled()) { LOG.debug(String.format("Created query %s for %s fields.", query.getQueryObject(), query.getFieldsObject())); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQuery.java index bd0116dff..6592f4706 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQuery.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQuery.java @@ -15,31 +15,17 @@ */ package org.springframework.data.mongodb.repository.query; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; - -import org.bson.BSON; import org.bson.Document; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.query.BasicQuery; import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.repository.query.ExpressionEvaluatingParameterBinder.BindingContext; +import org.springframework.data.mongodb.util.json.ParameterBindingContext; +import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec; import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; - -import com.mongodb.DBObject; -import com.mongodb.DBRef; -import com.mongodb.util.JSON; -import com.mongodb.util.JSONCallback; /** * Query to use a plain JSON String to create the {@link Query} to actually execute. @@ -53,16 +39,17 @@ public class StringBasedMongoQuery extends AbstractMongoQuery { private static final String COUNT_EXISTS_AND_DELETE = "Manually defined query for %s cannot be a count and exists or delete query at the same time!"; private static final Logger LOG = LoggerFactory.getLogger(StringBasedMongoQuery.class); - private static final ParameterBindingParser BINDING_PARSER = ParameterBindingParser.INSTANCE; + private static final ParameterBindingDocumentCodec CODEC = new ParameterBindingDocumentCodec(); private final String query; private final String fieldSpec; + + private final SpelExpressionParser expressionParser; + private final QueryMethodEvaluationContextProvider evaluationContextProvider; + private final boolean isCountQuery; private final boolean isExistsQuery; private final boolean isDeleteQuery; - private final List queryParameterBindings; - private final List fieldSpecParameterBindings; - private final ExpressionEvaluatingParameterBinder parameterBinder; /** * Creates a new {@link StringBasedMongoQuery} for the given {@link MongoQueryMethod}, {@link MongoOperations}, @@ -95,15 +82,10 @@ public class StringBasedMongoQuery extends AbstractMongoQuery { Assert.notNull(query, "Query must not be null!"); Assert.notNull(expressionParser, "SpelExpressionParser must not be null!"); - this.queryParameterBindings = new ArrayList(); - this.query = BINDING_PARSER.parseAndCollectParameterBindingsFromQueryIntoBindings(query, - this.queryParameterBindings); - - this.fieldSpecParameterBindings = new ArrayList(); - this.fieldSpec = BINDING_PARSER.parseAndCollectParameterBindingsFromQueryIntoBindings( - method.getFieldSpecification(), this.fieldSpecParameterBindings); - - this.parameterBinder = new ExpressionEvaluatingParameterBinder(expressionParser, evaluationContextProvider); + this.query = query; + this.expressionParser = expressionParser; + this.evaluationContextProvider = evaluationContextProvider; + this.fieldSpec = method.getFieldSpecification(); if (method.hasAnnotatedQuery()) { @@ -132,12 +114,13 @@ public class StringBasedMongoQuery extends AbstractMongoQuery { @Override protected Query createQuery(ConvertingParameterAccessor accessor) { - String queryString = parameterBinder.bind(this.query, accessor, - new BindingContext(getQueryMethod().getParameters(), queryParameterBindings)); - String fieldsString = parameterBinder.bind(this.fieldSpec, accessor, - new BindingContext(getQueryMethod().getParameters(), fieldSpecParameterBindings)); + ParameterBindingContext bindingContext = new ParameterBindingContext((accessor::getBindableValue), expressionParser, + evaluationContextProvider.getEvaluationContext(getQueryMethod().getParameters(), accessor.getValues())); - Query query = new BasicQuery(queryString, fieldsString).with(accessor.getSort()); + Document queryObject = CODEC.decode(this.query, bindingContext); + Document fieldsObject = CODEC.decode(this.fieldSpec, bindingContext); + + Query query = new BasicQuery(queryObject, fieldsObject).with(accessor.getSort()); if (LOG.isDebugEnabled()) { LOG.debug(String.format("Created query %s for %s fields.", query.getQueryObject(), query.getFieldsObject())); @@ -186,270 +169,4 @@ public class StringBasedMongoQuery extends AbstractMongoQuery { boolean isDeleteQuery) { return BooleanUtil.countBooleanTrueValues(isCountQuery, isExistsQuery, isDeleteQuery) > 1; } - - /** - * A parser that extracts the parameter bindings from a given query string. - * - * @author Thomas Darimont - */ - enum ParameterBindingParser { - - INSTANCE; - - private static final String EXPRESSION_PARAM_QUOTE = "'"; - private static final String EXPRESSION_PARAM_PREFIX = "?expr"; - private static final String INDEX_BASED_EXPRESSION_PARAM_START = "?#{"; - private static final String NAME_BASED_EXPRESSION_PARAM_START = ":#{"; - private static final char CURRLY_BRACE_OPEN = '{'; - private static final char CURRLY_BRACE_CLOSE = '}'; - private static final String PARAMETER_PREFIX = "_param_"; - private static final String PARSEABLE_PARAMETER = "\"" + PARAMETER_PREFIX + "$1\""; - private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); - private static final Pattern PARSEABLE_BINDING_PATTERN = Pattern.compile("\"?" + PARAMETER_PREFIX + "(\\d+)\"?"); - - private final static int PARAMETER_INDEX_GROUP = 1; - - /** - * Returns a list of {@link ParameterBinding}s found in the given {@code input} or an - * {@link Collections#emptyList()}. - * - * @param input can be empty. - * @param bindings must not be {@literal null}. - * @return - */ - public String parseAndCollectParameterBindingsFromQueryIntoBindings(String input, List bindings) { - - if (!StringUtils.hasText(input)) { - return input; - } - - Assert.notNull(bindings, "Parameter bindings must not be null!"); - - String transformedInput = transformQueryAndCollectExpressionParametersIntoBindings(input, bindings); - String parseableInput = makeParameterReferencesParseable(transformedInput); - -// Document.parse(parseableInput) - - collectParameterReferencesIntoBindings(bindings, - JSON.parse(parseableInput, new LenientPatternDecodingCallback())); - - return transformedInput; - } - - private static String transformQueryAndCollectExpressionParametersIntoBindings(String input, - List bindings) { - - StringBuilder result = new StringBuilder(); - - int startIndex = 0; - int currentPos = 0; - int exprIndex = 0; - - while (currentPos < input.length()) { - - int indexOfExpressionParameter = getIndexOfExpressionParameter(input, currentPos); - - // no expression parameter found - if (indexOfExpressionParameter < 0) { - break; - } - - int exprStart = indexOfExpressionParameter + 3; - currentPos = exprStart; - - // eat parameter expression - int curlyBraceOpenCnt = 1; - - while (curlyBraceOpenCnt > 0) { - switch (input.charAt(currentPos++)) { - case CURRLY_BRACE_OPEN: - curlyBraceOpenCnt++; - break; - case CURRLY_BRACE_CLOSE: - curlyBraceOpenCnt--; - break; - default: - } - } - - result.append(input.subSequence(startIndex, indexOfExpressionParameter)); - result.append(EXPRESSION_PARAM_QUOTE).append(EXPRESSION_PARAM_PREFIX); - result.append(exprIndex); - result.append(EXPRESSION_PARAM_QUOTE); - - bindings.add(new ParameterBinding(exprIndex, true, input.substring(exprStart, currentPos - 1))); - - startIndex = currentPos; - - exprIndex++; - } - - return result.append(input.subSequence(currentPos, input.length())).toString(); - } - - private static String makeParameterReferencesParseable(String input) { - - Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(input); - return matcher.replaceAll(PARSEABLE_PARAMETER); - } - - private static void collectParameterReferencesIntoBindings(List bindings, Object value) { - - if (value instanceof String) { - - String string = ((String) value).trim(); - potentiallyAddBinding(string, bindings); - - } else if (value instanceof Pattern) { - - String string = value.toString().trim(); - Matcher valueMatcher = PARSEABLE_BINDING_PATTERN.matcher(string); - - while (valueMatcher.find()) { - - int paramIndex = Integer.parseInt(valueMatcher.group(PARAMETER_INDEX_GROUP)); - - /* - * The pattern is used as a direct parameter replacement, e.g. 'field': ?1, - * therefore we treat it as not quoted to remain backwards compatible. - */ - boolean quoted = !string.equals(PARAMETER_PREFIX + paramIndex); - - bindings.add(new ParameterBinding(paramIndex, quoted)); - } - - } else if (value instanceof DBRef) { - - DBRef dbref = (DBRef) value; - - potentiallyAddBinding(dbref.getCollectionName(), bindings); - potentiallyAddBinding(dbref.getId().toString(), bindings); - - } else if (value instanceof Document) { - - Document document = (Document) value; - - for (String field : document.keySet()) { - collectParameterReferencesIntoBindings(bindings, field); - collectParameterReferencesIntoBindings(bindings, document.get(field)); - } - } else if (value instanceof DBObject) { - - DBObject dbo = (DBObject) value; - - for (String field : dbo.keySet()) { - collectParameterReferencesIntoBindings(bindings, field); - collectParameterReferencesIntoBindings(bindings, dbo.get(field)); - } - } - } - - private static void potentiallyAddBinding(String source, List bindings) { - - Matcher valueMatcher = PARSEABLE_BINDING_PATTERN.matcher(source); - - while (valueMatcher.find()) { - - int paramIndex = Integer.parseInt(valueMatcher.group(PARAMETER_INDEX_GROUP)); - boolean quoted = source.startsWith("'") || source.startsWith("\""); - - bindings.add(new ParameterBinding(paramIndex, quoted)); - } - } - - private static int getIndexOfExpressionParameter(String input, int position) { - - int indexOfExpressionParameter = input.indexOf(INDEX_BASED_EXPRESSION_PARAM_START, position); - - return indexOfExpressionParameter < 0 ? input.indexOf(NAME_BASED_EXPRESSION_PARAM_START, position) - : indexOfExpressionParameter; - } - } - - /** - * {@link JSONCallback} with lenient handling for {@link PatternSyntaxException} falling back to a placeholder - * {@link Pattern} for intermediate query document rendering. - */ - private static class LenientPatternDecodingCallback extends JSONCallback { - - private static final Pattern EMPTY_MARKER = Pattern.compile("__Spring_Data_MongoDB_Bind_Marker__"); - - /* - * (non-Javadoc) - * @see com.mongodb.util.JSONCallback#objectDone() - */ - @Override - public Object objectDone() { - return exceptionSwallowingStackReducingObjectDone(); - } - - private Object exceptionSwallowingStackReducingObjectDone/*CauseWeJustNeedTheStructureNotTheActualValue*/() { - - Object value; - - try { - return super.objectDone(); - } catch (PatternSyntaxException e) { - value = EMPTY_MARKER; - } - - if (!isStackEmpty()) { - _put(curName(), value); - } else { - value = !BSON.hasDecodeHooks() ? value : BSON.applyDecodingHooks(value); - setRoot(value); - } - return value; - } - } - - /** - * A generic parameter binding with name or position information. - * - * @author Thomas Darimont - */ - static class ParameterBinding { - - private final int parameterIndex; - private final boolean quoted; - private final @Nullable String expression; - - /** - * Creates a new {@link ParameterBinding} with the given {@code parameterIndex} and {@code quoted} information. - * - * @param parameterIndex - * @param quoted whether or not the parameter is already quoted. - */ - public ParameterBinding(int parameterIndex, boolean quoted) { - this(parameterIndex, quoted, null); - } - - public ParameterBinding(int parameterIndex, boolean quoted, @Nullable String expression) { - - this.parameterIndex = parameterIndex; - this.quoted = quoted; - this.expression = expression; - } - - public boolean isQuoted() { - return quoted; - } - - public int getParameterIndex() { - return parameterIndex; - } - - public String getParameter() { - return "?" + (isExpression() ? "expr" : "") + parameterIndex; - } - - @Nullable - public String getExpression() { - return expression; - } - - public boolean isExpression() { - return this.expression != null; - } - } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/DateTimeFormatter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/DateTimeFormatter.java new file mode 100644 index 000000000..d9c16c1c1 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/DateTimeFormatter.java @@ -0,0 +1,170 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import static java.time.format.DateTimeFormatter.*; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalQuery; +import java.util.Calendar; +import java.util.TimeZone; + +/** + * JsonBuffer implementation borrowed from MongoDB + * Inc. licensed under the Apache License, Version 2.0.
+ * Formatted and modified. + * + * @since 2.2 + * @author Jeff Yemin + * @author Ross Lawley + */ +class DateTimeFormatter { + + private static final FormatterImpl FORMATTER_IMPL; + + static { + FormatterImpl dateTimeHelper; + try { + dateTimeHelper = loadDateTimeFormatter("org.bson.json.DateTimeFormatter$Java8DateTimeFormatter"); + } catch (LinkageError e) { + // this is expected if running on a release prior to Java 8: fallback to JAXB. + dateTimeHelper = loadDateTimeFormatter("org.bson.json.DateTimeFormatter$JaxbDateTimeFormatter"); + } + + FORMATTER_IMPL = dateTimeHelper; + } + + private static FormatterImpl loadDateTimeFormatter(final String className) { + + try { + return (FormatterImpl) Class.forName(className).getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException e) { + // this is unexpected as it means the class itself is not found + throw new ExceptionInInitializerError(e); + } catch (InstantiationException e) { + // this is unexpected as it means the class can't be instantiated + throw new ExceptionInInitializerError(e); + } catch (IllegalAccessException e) { + // this is unexpected as it means the no-args constructor isn't accessible + throw new ExceptionInInitializerError(e); + } catch (NoSuchMethodException e) { + throw new ExceptionInInitializerError(e); + } catch (InvocationTargetException e) { + throw new ExceptionInInitializerError(e); + } + } + + static long parse(final String dateTimeString) { + return FORMATTER_IMPL.parse(dateTimeString); + } + + static String format(final long dateTime) { + return FORMATTER_IMPL.format(dateTime); + } + + private interface FormatterImpl { + long parse(String dateTimeString); + + String format(long dateTime); + } + + // Reflective use of DatatypeConverter avoids a compile-time dependency on the java.xml.bind module in Java 9 + static class JaxbDateTimeFormatter implements FormatterImpl { + + private static final Method DATATYPE_CONVERTER_PARSE_DATE_TIME_METHOD; + private static final Method DATATYPE_CONVERTER_PRINT_DATE_TIME_METHOD; + + static { + try { + DATATYPE_CONVERTER_PARSE_DATE_TIME_METHOD = Class.forName("javax.xml.bind.DatatypeConverter") + .getDeclaredMethod("parseDateTime", String.class); + DATATYPE_CONVERTER_PRINT_DATE_TIME_METHOD = Class.forName("javax.xml.bind.DatatypeConverter") + .getDeclaredMethod("printDateTime", Calendar.class); + } catch (NoSuchMethodException e) { + throw new ExceptionInInitializerError(e); + } catch (ClassNotFoundException e) { + throw new ExceptionInInitializerError(e); + } + } + + @Override + public long parse(final String dateTimeString) { + try { + return ((Calendar) DATATYPE_CONVERTER_PARSE_DATE_TIME_METHOD.invoke(null, dateTimeString)).getTimeInMillis(); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } catch (InvocationTargetException e) { + throw (RuntimeException) e.getCause(); + } + } + + @Override + public String format(final long dateTime) { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(dateTime); + calendar.setTimeZone(TimeZone.getTimeZone("Z")); + try { + return (String) DATATYPE_CONVERTER_PRINT_DATE_TIME_METHOD.invoke(null, calendar); + } catch (IllegalAccessException e) { + throw new IllegalStateException(); + } catch (InvocationTargetException e) { + throw (RuntimeException) e.getCause(); + } + } + } + + static class Java8DateTimeFormatter implements FormatterImpl { + + // if running on Java 8 or above then java.time.format.DateTimeFormatter will be available and initialization will + // succeed. + // Otherwise it will fail. + static { + try { + Class.forName("java.time.format.DateTimeFormatter"); + } catch (ClassNotFoundException e) { + throw new ExceptionInInitializerError(e); + } + } + + @Override + public long parse(final String dateTimeString) { + try { + return ISO_OFFSET_DATE_TIME.parse(dateTimeString, new TemporalQuery() { + @Override + public Instant queryFrom(final TemporalAccessor temporal) { + return Instant.from(temporal); + } + }).toEpochMilli(); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + @Override + public String format(final long dateTime) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(dateTime), ZoneId.of("Z")).format(ISO_OFFSET_DATE_TIME); + } + } + + private DateTimeFormatter() {} +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonBuffer.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonBuffer.java new file mode 100644 index 000000000..185a90d4c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonBuffer.java @@ -0,0 +1,73 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import org.bson.json.JsonParseException; + +/** + * JsonBuffer implementation borrowed from MongoDB + * Inc. licensed under the Apache License, Version 2.0.
+ * Formatted and modified. + * + * @since 2.2 + * @author Jeff Yemin + * @author Ross Lawley + */ +class JsonBuffer { + + private final String buffer; + private int position; + private boolean eof; + + JsonBuffer(final String buffer) { + this.buffer = buffer; + } + + public int getPosition() { + return position; + } + + public void setPosition(final int position) { + this.position = position; + } + + public int read() { + if (eof) { + throw new JsonParseException("Trying to read past EOF."); + } else if (position >= buffer.length()) { + eof = true; + return -1; + } else { + return buffer.charAt(position++); + } + } + + public void unread(final int c) { + eof = false; + if (c != -1 && buffer.charAt(position - 1) == c) { + position--; + } + } + + public String substring(final int beginIndex) { + return buffer.substring(beginIndex); + } + + public String substring(final int beginIndex, final int endIndex) { + return buffer.substring(beginIndex, endIndex); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonScanner.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonScanner.java new file mode 100644 index 000000000..f38a2a8ca --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonScanner.java @@ -0,0 +1,623 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import org.bson.BsonRegularExpression; +import org.bson.json.JsonParseException; + +/** + * Parses the string representation of a JSON object into a set of {@link JsonToken}-derived objects.
+ * JsonScanner implementation borrowed from MongoDB + * Inc. licensed under the Apache License, Version 2.0.
+ * Formatted and modified to allow reading Spring Data specific placeholder values. + * + * @since 2.2 + * @author Jeff Yemin + * @author Trisha Gee + * @author Robert Guo + * @author Ross Lawley + * @author Christoph Strobl + */ +class JsonScanner { + + private final JsonBuffer buffer; + + JsonScanner(final String json) { + this(new JsonBuffer(json)); + } + + JsonScanner(final JsonBuffer buffer) { + this.buffer = buffer; + } + + /** + * @param newPosition the new position of the cursor position in the buffer + */ + public void setBufferPosition(final int newPosition) { + buffer.setPosition(newPosition); + } + + /** + * @return the current location of the cursor in the buffer + */ + public int getBufferPosition() { + return buffer.getPosition(); + } + + /** + * Finds and returns the next complete token from this scanner. If scanner reached the end of the source, it will + * return a token with {@code JSONTokenType.END_OF_FILE} type. + * + * @return The next token. + * @throws JsonParseException if source is invalid. + */ + public JsonToken nextToken() { + + int c = buffer.read(); + while (c != -1 && Character.isWhitespace(c)) { + c = buffer.read(); + } + if (c == -1) { + return new JsonToken(JsonTokenType.END_OF_FILE, ""); + } + + switch (c) { + case '{': + return new JsonToken(JsonTokenType.BEGIN_OBJECT, "{"); + case '}': + return new JsonToken(JsonTokenType.END_OBJECT, "}"); + case '[': + return new JsonToken(JsonTokenType.BEGIN_ARRAY, "["); + case ']': + return new JsonToken(JsonTokenType.END_ARRAY, "]"); + case '(': + return new JsonToken(JsonTokenType.LEFT_PAREN, "("); + case ')': + return new JsonToken(JsonTokenType.RIGHT_PAREN, ")"); + case ':': + + c = buffer.read(); + buffer.unread(c); + + if (c == '#') { // for binding the SQL style ':#{#firstname}"' + return scanBindString(); + } + + return new JsonToken(JsonTokenType.COLON, ":"); + case ',': + return new JsonToken(JsonTokenType.COMMA, ","); + case '\'': + case '"': + return scanString((char) c); + case '/': + return scanRegularExpression(); + default: + if (c == '-' || Character.isDigit(c)) { + return scanNumber((char) c); + } else if (c == '$' || c == '_' || Character.isLetter(c)) { + return scanUnquotedString(); + } else if (c == '?') { // for binding parameters. Both simple and SpEL ones. + return scanBindString(); + } else { + int position = buffer.getPosition(); + buffer.unread(c); + throw new JsonParseException("Invalid JSON input. Position: %d. Character: '%c'.", position, c); + } + } + } + + /** + * Reads {@code RegularExpressionToken} from source. The following variants of lexemes are possible: + * + *
+	 *  /pattern/
+	 *  /\(pattern\)/
+	 *  /pattern/ims
+	 * 
+ * + * Options can include 'i','m','x','s' + * + * @return The regular expression token. + * @throws JsonParseException if regular expression representation is not valid. + */ + private JsonToken scanRegularExpression() { + + int start = buffer.getPosition() - 1; + int options = -1; + + RegularExpressionState state = RegularExpressionState.IN_PATTERN; + while (true) { + int c = buffer.read(); + switch (state) { + case IN_PATTERN: + switch (c) { + case -1: + state = RegularExpressionState.INVALID; + break; + case '/': + state = RegularExpressionState.IN_OPTIONS; + options = buffer.getPosition(); + break; + case '\\': + state = RegularExpressionState.IN_ESCAPE_SEQUENCE; + break; + default: + state = RegularExpressionState.IN_PATTERN; + break; + } + break; + case IN_ESCAPE_SEQUENCE: + state = RegularExpressionState.IN_PATTERN; + break; + case IN_OPTIONS: + switch (c) { + case 'i': + case 'm': + case 'x': + case 's': + state = RegularExpressionState.IN_OPTIONS; + break; + case ',': + case '}': + case ']': + case ')': + case -1: + state = RegularExpressionState.DONE; + break; + default: + if (Character.isWhitespace(c)) { + state = RegularExpressionState.DONE; + } else { + state = RegularExpressionState.INVALID; + } + break; + } + break; + default: + break; + } + + switch (state) { + case DONE: + buffer.unread(c); + int end = buffer.getPosition(); + BsonRegularExpression regex = new BsonRegularExpression(buffer.substring(start + 1, options - 1), + buffer.substring(options, end)); + return new JsonToken(JsonTokenType.REGULAR_EXPRESSION, regex); + case INVALID: + throw new JsonParseException("Invalid JSON regular expression. Position: %d.", buffer.getPosition()); + default: + } + } + } + + /** + * Reads {@code StringToken} from source. + * + * @return The string token. + */ + private JsonToken scanBindString() { + + int start = buffer.getPosition() - 1; + int c = buffer.read(); + + int charCount = 0; + boolean isExpression = false; + int parenthesisCount = 0; + + while (c == '$' || c == '_' || Character.isLetterOrDigit(c) || c == '#' || c == '{' || c == '[' || c == ']' + || (isExpression && isExpressionAllowedChar(c))) { + + if (charCount == 0 && c == '#') { + isExpression = true; + } else if (isExpression) { + if (c == '{') { + parenthesisCount++; + } else if (c == '}') { + + parenthesisCount--; + if (parenthesisCount == 0) { + buffer.read(); + break; + } + } + } + charCount++; + c = buffer.read(); + } + buffer.unread(c); + String lexeme = buffer.substring(start, buffer.getPosition()); + + return new JsonToken(JsonTokenType.UNQUOTED_STRING, lexeme); + } + + private static boolean isExpressionAllowedChar(int c) { + + return (c == '+' || // + c == '-' || // + c == ':' || // + c == '.' || // + c == ',' || // + c == '*' || // + c == '/' || // + c == '%' || // + c == '(' || // + c == ')' || // + c == '[' || // + c == ']' || // + c == '#' || // + c == '{' || // + c == '}' || // + c == '@' || // + c == '^' || // + c == '!' || // + c == '=' || // + c == '&' || // + c == '|' || // + c == '?' || // + c == '$' || // + c == '>' || // + c == '<' || // + c == '"' || // + c == '\'' || // + c == ' '); + } + + /** + * Reads {@code StringToken} from source. + * + * @return The string token. + */ + private JsonToken scanUnquotedString() { + int start = buffer.getPosition() - 1; + int c = buffer.read(); + while (c == '$' || c == '_' || Character.isLetterOrDigit(c)) { + c = buffer.read(); + } + buffer.unread(c); + String lexeme = buffer.substring(start, buffer.getPosition()); + return new JsonToken(JsonTokenType.UNQUOTED_STRING, lexeme); + } + + /** + * Reads number token from source. The following variants of lexemes are possible: + * + *
+	 *  12
+	 *  123
+	 *  -0
+	 *  -345
+	 *  -0.0
+	 *  0e1
+	 *  0e-1
+	 *  -0e-1
+	 *  1e12
+	 *  -Infinity
+	 * 
+ * + * @return The number token. + * @throws JsonParseException if number representation is invalid. + */ + // CHECKSTYLE:OFF + private JsonToken scanNumber(final char firstChar) { + + int c = firstChar; + + int start = buffer.getPosition() - 1; + + NumberState state; + + switch (c) { + case '-': + state = NumberState.SAW_LEADING_MINUS; + break; + case '0': + state = NumberState.SAW_LEADING_ZERO; + break; + default: + state = NumberState.SAW_INTEGER_DIGITS; + break; + } + + JsonTokenType type = JsonTokenType.INT64; + + while (true) { + c = buffer.read(); + switch (state) { + case SAW_LEADING_MINUS: + switch (c) { + case '0': + state = NumberState.SAW_LEADING_ZERO; + break; + case 'I': + state = NumberState.SAW_MINUS_I; + break; + default: + if (Character.isDigit(c)) { + state = NumberState.SAW_INTEGER_DIGITS; + } else { + state = NumberState.INVALID; + } + break; + } + break; + case SAW_LEADING_ZERO: + switch (c) { + case '.': + state = NumberState.SAW_DECIMAL_POINT; + break; + case 'e': + case 'E': + state = NumberState.SAW_EXPONENT_LETTER; + break; + case ',': + case '}': + case ']': + case ')': + case -1: + state = NumberState.DONE; + break; + default: + if (Character.isDigit(c)) { + state = NumberState.SAW_INTEGER_DIGITS; + } else if (Character.isWhitespace(c)) { + state = NumberState.DONE; + } else { + state = NumberState.INVALID; + } + break; + } + break; + case SAW_INTEGER_DIGITS: + switch (c) { + case '.': + state = NumberState.SAW_DECIMAL_POINT; + break; + case 'e': + case 'E': + state = NumberState.SAW_EXPONENT_LETTER; + break; + case ',': + case '}': + case ']': + case ')': + case -1: + state = NumberState.DONE; + break; + default: + if (Character.isDigit(c)) { + state = NumberState.SAW_INTEGER_DIGITS; + } else if (Character.isWhitespace(c)) { + state = NumberState.DONE; + } else { + state = NumberState.INVALID; + } + break; + } + break; + case SAW_DECIMAL_POINT: + type = JsonTokenType.DOUBLE; + if (Character.isDigit(c)) { + state = NumberState.SAW_FRACTION_DIGITS; + } else { + state = NumberState.INVALID; + } + break; + case SAW_FRACTION_DIGITS: + switch (c) { + case 'e': + case 'E': + state = NumberState.SAW_EXPONENT_LETTER; + break; + case ',': + case '}': + case ']': + case ')': + case -1: + state = NumberState.DONE; + break; + default: + if (Character.isDigit(c)) { + state = NumberState.SAW_FRACTION_DIGITS; + } else if (Character.isWhitespace(c)) { + state = NumberState.DONE; + } else { + state = NumberState.INVALID; + } + break; + } + break; + case SAW_EXPONENT_LETTER: + type = JsonTokenType.DOUBLE; + switch (c) { + case '+': + case '-': + state = NumberState.SAW_EXPONENT_SIGN; + break; + default: + if (Character.isDigit(c)) { + state = NumberState.SAW_EXPONENT_DIGITS; + } else { + state = NumberState.INVALID; + } + break; + } + break; + case SAW_EXPONENT_SIGN: + if (Character.isDigit(c)) { + state = NumberState.SAW_EXPONENT_DIGITS; + } else { + state = NumberState.INVALID; + } + break; + case SAW_EXPONENT_DIGITS: + switch (c) { + case ',': + case '}': + case ']': + case ')': + state = NumberState.DONE; + break; + default: + if (Character.isDigit(c)) { + state = NumberState.SAW_EXPONENT_DIGITS; + } else if (Character.isWhitespace(c)) { + state = NumberState.DONE; + } else { + state = NumberState.INVALID; + } + break; + } + break; + case SAW_MINUS_I: + boolean sawMinusInfinity = true; + char[] nfinity = new char[] { 'n', 'f', 'i', 'n', 'i', 't', 'y' }; + for (int i = 0; i < nfinity.length; i++) { + if (c != nfinity[i]) { + sawMinusInfinity = false; + break; + } + c = buffer.read(); + } + if (sawMinusInfinity) { + type = JsonTokenType.DOUBLE; + switch (c) { + case ',': + case '}': + case ']': + case ')': + case -1: + state = NumberState.DONE; + break; + default: + if (Character.isWhitespace(c)) { + state = NumberState.DONE; + } else { + state = NumberState.INVALID; + } + break; + } + } else { + state = NumberState.INVALID; + } + break; + default: + } + + switch (state) { + case INVALID: + throw new JsonParseException("Invalid JSON number"); + case DONE: + buffer.unread(c); + String lexeme = buffer.substring(start, buffer.getPosition()); + if (type == JsonTokenType.DOUBLE) { + return new JsonToken(JsonTokenType.DOUBLE, Double.parseDouble(lexeme)); + } else { + long value = Long.parseLong(lexeme); + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + return new JsonToken(JsonTokenType.INT64, value); + } else { + return new JsonToken(JsonTokenType.INT32, (int) value); + } + } + default: + } + } + + } + // CHECKSTYLE:ON + + /** + * Reads {@code StringToken} from source. + * + * @return The string token. + */ + // CHECKSTYLE:OFF + private JsonToken scanString(final char quoteCharacter) { + + StringBuilder sb = new StringBuilder(); + + while (true) { + int c = buffer.read(); + switch (c) { + case '\\': + c = buffer.read(); + switch (c) { + case '\'': + sb.append('\''); + break; + case '"': + sb.append('"'); + break; + case '\\': + sb.append('\\'); + break; + case '/': + sb.append('/'); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + int u1 = buffer.read(); + int u2 = buffer.read(); + int u3 = buffer.read(); + int u4 = buffer.read(); + if (u4 != -1) { + String hex = new String(new char[] { (char) u1, (char) u2, (char) u3, (char) u4 }); + sb.append((char) Integer.parseInt(hex, 16)); + } + break; + default: + throw new JsonParseException("Invalid escape sequence in JSON string '\\%c'.", c); + } + break; + + default: + if (c == quoteCharacter) { + return new JsonToken(JsonTokenType.STRING, sb.toString()); + } + if (c != -1) { + sb.append((char) c); + } + } + if (c == -1) { + throw new JsonParseException("End of file in JSON string."); + } + } + } + + private enum NumberState { + SAW_LEADING_MINUS, SAW_LEADING_ZERO, SAW_INTEGER_DIGITS, SAW_DECIMAL_POINT, SAW_FRACTION_DIGITS, SAW_EXPONENT_LETTER, SAW_EXPONENT_SIGN, SAW_EXPONENT_DIGITS, SAW_MINUS_I, DONE, INVALID + } + + private enum RegularExpressionState { + IN_PATTERN, IN_ESCAPE_SEQUENCE, IN_OPTIONS, DONE, INVALID + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonToken.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonToken.java new file mode 100644 index 000000000..2b13ebde4 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonToken.java @@ -0,0 +1,86 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import static java.lang.String.*; + +import org.bson.BsonDouble; +import org.bson.json.JsonParseException; +import org.bson.types.Decimal128; + +/** + * JsonToken implementation borrowed from MongoDB + * Inc. licensed under the Apache License, Version 2.0.
+ * + * @since 2.2 + * @author Jeff Yemin + * @author Ross Lawley + */ +class JsonToken { + + private final Object value; + private final JsonTokenType type; + + JsonToken(final JsonTokenType type, final Object value) { + + this.value = value; + this.type = type; + } + + Object getValue() { + return value; + } + + T getValue(final Class clazz) { + + try { + if (Long.class == clazz) { + if (value instanceof Integer) { + return clazz.cast(((Integer) value).longValue()); + } else if (value instanceof String) { + return clazz.cast(Long.valueOf((String) value)); + } + } else if (Integer.class == clazz) { + if (value instanceof String) { + return clazz.cast(Integer.valueOf((String) value)); + } + } else if (Double.class == clazz) { + if (value instanceof String) { + return clazz.cast(Double.valueOf((String) value)); + } + } else if (Decimal128.class == clazz) { + if (value instanceof Integer) { + return clazz.cast(new Decimal128((Integer) value)); + } else if (value instanceof Long) { + return clazz.cast(new Decimal128((Long) value)); + } else if (value instanceof Double) { + return clazz.cast(new BsonDouble((Double) value).decimal128Value()); + } else if (value instanceof String) { + return clazz.cast(Decimal128.parse((String) value)); + } + } + + return clazz.cast(value); + } catch (Exception e) { + throw new JsonParseException(format("Exception converting value '%s' to type %s", value, clazz.getName()), e); + } + } + + public JsonTokenType getType() { + return type; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonTokenType.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonTokenType.java new file mode 100644 index 000000000..bda090c92 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/JsonTokenType.java @@ -0,0 +1,107 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +/** + * JsonTokenType implementation borrowed from MongoDB + * Inc. licensed under the Apache License, Version 2.0.
+ * + * @since 2.2 + * @author Jeff Yemin + * @author Ross Lawley + */ +enum JsonTokenType { + /** + * An invalid token. + */ + INVALID, + + /** + * A begin array token (a '['). + */ + BEGIN_ARRAY, + + /** + * A begin object token (a '{'). + */ + BEGIN_OBJECT, + + /** + * An end array token (a ']'). + */ + END_ARRAY, + + /** + * A left parenthesis (a '('). + */ + LEFT_PAREN, + + /** + * A right parenthesis (a ')'). + */ + RIGHT_PAREN, + + /** + * An end object token (a '}'). + */ + END_OBJECT, + + /** + * A colon token (a ':'). + */ + COLON, + + /** + * A comma token (a ','). + */ + COMMA, + + /** + * A Double token. + */ + DOUBLE, + + /** + * An Int32 token. + */ + INT32, + + /** + * And Int64 token. + */ + INT64, + + /** + * A regular expression token. + */ + REGULAR_EXPRESSION, + + /** + * A string token. + */ + STRING, + + /** + * An unquoted string token. + */ + UNQUOTED_STRING, + + /** + * An end of file token. + */ + END_OF_FILE +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingContext.java new file mode 100644 index 000000000..81d7b77d6 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingContext.java @@ -0,0 +1,52 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Nullable; + +/** + * Reusable context for binding parameters to an placeholder or a SpEL expression within a JSON structure.
+ * To be used along with {@link ParameterBindingDocumentCodec#decode(String, ParameterBindingContext)}. + * + * @author Christoph Strobl + * @since 2.2 + */ +@RequiredArgsConstructor +@Getter +public class ParameterBindingContext { + + private final ValueProvider valueProvider; + private final SpelExpressionParser expressionParser; + private final EvaluationContext evaluationContext; + + @Nullable + public Object bindableValueForIndex(int index) { + return valueProvider.getBindableValue(index); + } + + @Nullable + public Object evaluateExpression(String expressionString) { + + Expression expression = expressionParser.parseExpression(expressionString); + return expression.getValue(this.evaluationContext, Object.class); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingDocumentCodec.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingDocumentCodec.java new file mode 100644 index 000000000..740cd6add --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingDocumentCodec.java @@ -0,0 +1,309 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import static java.util.Arrays.*; +import static org.bson.assertions.Assertions.*; +import static org.bson.codecs.configuration.CodecRegistries.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bson.AbstractBsonReader.State; +import org.bson.BsonBinarySubType; +import org.bson.BsonDocument; +import org.bson.BsonDocumentWriter; +import org.bson.BsonReader; +import org.bson.BsonType; +import org.bson.BsonValue; +import org.bson.BsonWriter; +import org.bson.Document; +import org.bson.Transformer; +import org.bson.codecs.*; +import org.bson.codecs.configuration.CodecRegistry; +import org.springframework.data.spel.EvaluationContextProvider; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +/** + * A {@link Codec} implementation that allows binding parameters to placeholders or SpEL expressions when decoding a + * JSON String.
+ * Modified version of MongoDB + * Inc. DocumentCodec licensed under the Apache License, Version 2.0.
+ * + * @since 2.2 + * @author Jeff Yemin + * @author Ross Lawley + * @author Ralph Schaer + * @author Christoph Strobl + */ +public class ParameterBindingDocumentCodec implements CollectibleCodec { + + private static final String ID_FIELD_NAME = "_id"; + private static final CodecRegistry DEFAULT_REGISTRY = fromProviders( + asList(new ValueCodecProvider(), new BsonValueCodecProvider(), new DocumentCodecProvider())); + private static final BsonTypeClassMap DEFAULT_BSON_TYPE_CLASS_MAP = new BsonTypeClassMap(); + + private final BsonTypeCodecMap bsonTypeCodecMap; + private final CodecRegistry registry; + private final IdGenerator idGenerator; + private final Transformer valueTransformer; + + /** + * Construct a new instance with a default {@code CodecRegistry}. + */ + public ParameterBindingDocumentCodec() { + this(DEFAULT_REGISTRY); + } + + /** + * Construct a new instance with the given registry. + * + * @param registry the registry + * @since 3.5 + */ + public ParameterBindingDocumentCodec(final CodecRegistry registry) { + this(registry, DEFAULT_BSON_TYPE_CLASS_MAP); + } + + /** + * Construct a new instance with the given registry and BSON type class map. + * + * @param registry the registry + * @param bsonTypeClassMap the BSON type class map + */ + public ParameterBindingDocumentCodec(final CodecRegistry registry, final BsonTypeClassMap bsonTypeClassMap) { + this(registry, bsonTypeClassMap, null); + } + + /** + * Construct a new instance with the given registry and BSON type class map. The transformer is applied as a last step + * when decoding values, which allows users of this codec to control the decoding process. For example, a user of this + * class could substitute a value decoded as a Document with an instance of a special purpose class (e.g., one + * representing a DBRef in MongoDB). + * + * @param registry the registry + * @param bsonTypeClassMap the BSON type class map + * @param valueTransformer the value transformer to use as a final step when decoding the value of any field in the + * document + */ + public ParameterBindingDocumentCodec(final CodecRegistry registry, final BsonTypeClassMap bsonTypeClassMap, + final Transformer valueTransformer) { + this.registry = notNull("registry", registry); + this.bsonTypeCodecMap = new BsonTypeCodecMap(notNull("bsonTypeClassMap", bsonTypeClassMap), registry); + this.idGenerator = new ObjectIdGenerator(); + this.valueTransformer = valueTransformer != null ? valueTransformer : new Transformer() { + @Override + public Object transform(final Object value) { + return value; + } + }; + } + + @Override + public boolean documentHasId(final Document document) { + return document.containsKey(ID_FIELD_NAME); + } + + @Override + public BsonValue getDocumentId(final Document document) { + if (!documentHasId(document)) { + throw new IllegalStateException("The document does not contain an _id"); + } + + Object id = document.get(ID_FIELD_NAME); + if (id instanceof BsonValue) { + return (BsonValue) id; + } + + BsonDocument idHoldingDocument = new BsonDocument(); + BsonWriter writer = new BsonDocumentWriter(idHoldingDocument); + writer.writeStartDocument(); + writer.writeName(ID_FIELD_NAME); + writeValue(writer, EncoderContext.builder().build(), id); + writer.writeEndDocument(); + return idHoldingDocument.get(ID_FIELD_NAME); + } + + @Override + public Document generateIdIfAbsentFromDocument(final Document document) { + if (!documentHasId(document)) { + document.put(ID_FIELD_NAME, idGenerator.generate()); + } + return document; + } + + @Override + public void encode(final BsonWriter writer, final Document document, final EncoderContext encoderContext) { + writeMap(writer, document, encoderContext); + } + + public Document decode(@Nullable String json, Object[] values) { + + return decode(json, new ParameterBindingContext((index) -> values[index], new SpelExpressionParser(), + EvaluationContextProvider.DEFAULT.getEvaluationContext(values))); + } + + public Document decode(@Nullable String json, ParameterBindingContext bindingContext) { + + if (StringUtils.isEmpty(json)) { + return new Document(); + } + + ParameterBindingJsonReader reader = new ParameterBindingJsonReader(json, bindingContext); + return this.decode(reader, DecoderContext.builder().build()); + } + + @Override + public Document decode(final BsonReader reader, final DecoderContext decoderContext) { + + if (reader instanceof ParameterBindingJsonReader) { + ParameterBindingJsonReader bindingReader = (ParameterBindingJsonReader) reader; + + // check if the reader has actually found something to replace on top level and did so. + // binds just placeholder queries like: `@Query(?0)` + if (bindingReader.currentValue instanceof org.bson.Document) { + return (Document) bindingReader.currentValue; + } + } + + Document document = new Document(); + reader.readStartDocument(); + while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { + String fieldName = reader.readName(); + document.put(fieldName, readValue(reader, decoderContext)); + } + + reader.readEndDocument(); + + return document; + } + + @Override + public Class getEncoderClass() { + return Document.class; + } + + private void beforeFields(final BsonWriter bsonWriter, final EncoderContext encoderContext, + final Map document) { + if (encoderContext.isEncodingCollectibleDocument() && document.containsKey(ID_FIELD_NAME)) { + bsonWriter.writeName(ID_FIELD_NAME); + writeValue(bsonWriter, encoderContext, document.get(ID_FIELD_NAME)); + } + } + + private boolean skipField(final EncoderContext encoderContext, final String key) { + return encoderContext.isEncodingCollectibleDocument() && key.equals(ID_FIELD_NAME); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void writeValue(final BsonWriter writer, final EncoderContext encoderContext, final Object value) { + if (value == null) { + writer.writeNull(); + } else if (value instanceof Iterable) { + writeIterable(writer, (Iterable) value, encoderContext.getChildContext()); + } else if (value instanceof Map) { + writeMap(writer, (Map) value, encoderContext.getChildContext()); + } else { + Codec codec = registry.get(value.getClass()); + encoderContext.encodeWithChildContext(codec, writer, value); + } + } + + private void writeMap(final BsonWriter writer, final Map map, final EncoderContext encoderContext) { + writer.writeStartDocument(); + + beforeFields(writer, encoderContext, map); + + for (final Map.Entry entry : map.entrySet()) { + if (skipField(encoderContext, entry.getKey())) { + continue; + } + writer.writeName(entry.getKey()); + writeValue(writer, encoderContext, entry.getValue()); + } + writer.writeEndDocument(); + } + + private void writeIterable(final BsonWriter writer, final Iterable list, + final EncoderContext encoderContext) { + writer.writeStartArray(); + for (final Object value : list) { + writeValue(writer, encoderContext, value); + } + writer.writeEndArray(); + } + + private Object readValue(final BsonReader reader, final DecoderContext decoderContext) { + + if (reader instanceof ParameterBindingJsonReader) { + + ParameterBindingJsonReader bindingReader = (ParameterBindingJsonReader) reader; + + // check if the reader has actually found something to replaceand did so. + // resets the reader state to move on after the actual value + // returns the replacement value + if (bindingReader.currentValue != null) { + + Object value = bindingReader.currentValue; + bindingReader.setState(State.TYPE); + bindingReader.currentValue = null; + return value; + } + } + + BsonType bsonType = reader.getCurrentBsonType(); + if (bsonType == BsonType.NULL) { + reader.readNull(); + return null; + } else if (bsonType == BsonType.ARRAY) { + return readList(reader, decoderContext); + } else if (bsonType == BsonType.BINARY && BsonBinarySubType.isUuid(reader.peekBinarySubType()) + && reader.peekBinarySize() == 16) { + return registry.get(UUID.class).decode(reader, decoderContext); + } + + // By default the registry uses DocumentCodec for parsing. + // We need to reroute that to our very own implementation or we'll end up only mapping half the placeholders. + Codec codecToUse = bsonTypeCodecMap.get(bsonType); + if (codecToUse instanceof org.bson.codecs.DocumentCodec) { + codecToUse = this; + } + + return valueTransformer.transform(codecToUse.decode(reader, decoderContext)); + } + + private List readList(final BsonReader reader, final DecoderContext decoderContext) { + reader.readStartArray(); + List list = new ArrayList<>(); + while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { + + Object listValue = readValue(reader, decoderContext); + if (listValue instanceof Collection) { + list.addAll((Collection) listValue); + break; + } + list.add(listValue); + } + reader.readEndArray(); + return list; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReader.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReader.java new file mode 100644 index 000000000..4eb2f08d5 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReader.java @@ -0,0 +1,1586 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import static java.lang.String.*; + +import lombok.Data; + +import java.text.DateFormat; +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bson.*; +import org.bson.internal.Base64; +import org.bson.json.JsonParseException; +import org.bson.types.Decimal128; +import org.bson.types.MaxKey; +import org.bson.types.MinKey; +import org.bson.types.ObjectId; +import org.springframework.data.spel.EvaluationContextProvider; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; +import org.springframework.util.NumberUtils; + +/** + * Reads a JSON and evaluates placehoders and SpEL expressions. Modified version of MongoDB Inc. + * JsonReader licensed under the Apache License, Version 2.0.
+ * + * @author Jeff Yemin + * @author Ross Lawley + * @author Thrisha Gee + * @author Robert Guo + * @author Florian Buecklers + * @author Brendon Puntin + * @author Christoph Strobl + */ +public class ParameterBindingJsonReader extends AbstractBsonReader { + + private static final Pattern PARAMETER_ONLY_BINDING_PATTERN = Pattern.compile("^\\?(\\d+)$"); + private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); + private static final Pattern EXPRESSION_BINDING_PATTERN = Pattern.compile("[\\?:]#\\{.*\\}"); + + private final ParameterBindingContext bindingContext; + + private final JsonScanner scanner; + private JsonToken pushedToken; + Object currentValue; + private Mark mark; + + /** + * Constructs a new instance with the given JSON string. + * + * @param json A string representation of a JSON. + */ + public ParameterBindingJsonReader(final String json) { + this(json, new Object[] {}); + } + + /** + * Constructs a new instance with the given JSON string. + * + * @param json A string representation of a JSON. + */ + public ParameterBindingJsonReader(String json, Object[] values) { + + this(json, (index) -> values[index], new SpelExpressionParser(), + EvaluationContextProvider.DEFAULT.getEvaluationContext(values)); + } + + public ParameterBindingJsonReader(String json, ValueProvider accessor, SpelExpressionParser spelExpressionParser, + EvaluationContext evaluationContext) { + + this.scanner = new JsonScanner(json); + setContext(new Context(null, BsonContextType.TOP_LEVEL)); + + this.bindingContext = new ParameterBindingContext(accessor, spelExpressionParser, evaluationContext); + + Matcher matcher = PARAMETER_ONLY_BINDING_PATTERN.matcher(json); + if (matcher.find()) { + currentValue = bindableValueFor(new JsonToken(JsonTokenType.UNQUOTED_STRING, json)).getValue(); + } + } + + public ParameterBindingJsonReader(String json, ParameterBindingContext bindingContext) { + + this.scanner = new JsonScanner(json); + setContext(new Context(null, BsonContextType.TOP_LEVEL)); + + this.bindingContext = bindingContext; + + Matcher matcher = PARAMETER_ONLY_BINDING_PATTERN.matcher(json); + if (matcher.find()) { + currentValue = bindableValueFor(new JsonToken(JsonTokenType.UNQUOTED_STRING, json)).getValue(); + } + } + + @Override + protected BsonBinary doReadBinaryData() { + return (BsonBinary) currentValue; + } + + @Override + protected byte doPeekBinarySubType() { + return doReadBinaryData().getType(); + } + + @Override + protected int doPeekBinarySize() { + return doReadBinaryData().getData().length; + } + + @Override + protected boolean doReadBoolean() { + return (Boolean) currentValue; + } + + // CHECKSTYLE:OFF + @Override + public BsonType readBsonType() { + + if (isClosed()) { + throw new IllegalStateException("This instance has been closed"); + } + if (getState() == State.INITIAL || getState() == State.DONE || getState() == State.SCOPE_DOCUMENT) { + // in JSON the top level value can be of any type so fall through + setState(State.TYPE); + } + if (getState() != State.TYPE) { + throwInvalidState("readBSONType", State.TYPE); + } + + if (getContext().getContextType() == BsonContextType.DOCUMENT) { + JsonToken nameToken = popToken(); + switch (nameToken.getType()) { + case STRING: + case UNQUOTED_STRING: + setCurrentName(bindableValueFor(nameToken).getValue().toString()); + break; + case END_OBJECT: + setState(State.END_OF_DOCUMENT); + return BsonType.END_OF_DOCUMENT; + default: + throw new JsonParseException("JSON reader was expecting a name but found '%s'.", nameToken.getValue()); + } + + JsonToken colonToken = popToken(); + if (colonToken.getType() != JsonTokenType.COLON) { + throw new JsonParseException("JSON reader was expecting ':' but found '%s'.", colonToken.getValue()); + } + } + + JsonToken token = popToken(); + if (getContext().getContextType() == BsonContextType.ARRAY && token.getType() == JsonTokenType.END_ARRAY) { + setState(State.END_OF_ARRAY); + return BsonType.END_OF_DOCUMENT; + } + + boolean noValueFound = false; + BindableValue bindableValue = null; + + switch (token.getType()) { + case BEGIN_ARRAY: + setCurrentBsonType(BsonType.ARRAY); + break; + case BEGIN_OBJECT: + visitExtendedJSON(); + break; + case DOUBLE: + setCurrentBsonType(BsonType.DOUBLE); + currentValue = token.getValue(); + break; + case END_OF_FILE: + setCurrentBsonType(BsonType.END_OF_DOCUMENT); + break; + case INT32: + setCurrentBsonType(BsonType.INT32); + currentValue = token.getValue(); + break; + case INT64: + setCurrentBsonType(BsonType.INT64); + currentValue = token.getValue(); + break; + case REGULAR_EXPRESSION: + + setCurrentBsonType(BsonType.REGULAR_EXPRESSION); + currentValue = bindableValueFor(token).getValue().toString(); + break; + case STRING: + + setCurrentBsonType(BsonType.STRING); + currentValue = bindableValueFor(token).getValue().toString(); + break; + case UNQUOTED_STRING: + + String value = token.getValue(String.class); + + if ("false".equals(value) || "true".equals(value)) { + setCurrentBsonType(BsonType.BOOLEAN); + currentValue = Boolean.parseBoolean(value); + } else if ("Infinity".equals(value)) { + setCurrentBsonType(BsonType.DOUBLE); + currentValue = Double.POSITIVE_INFINITY; + } else if ("NaN".equals(value)) { + setCurrentBsonType(BsonType.DOUBLE); + currentValue = Double.NaN; + } else if ("null".equals(value)) { + setCurrentBsonType(BsonType.NULL); + } else if ("undefined".equals(value)) { + setCurrentBsonType(BsonType.UNDEFINED); + } else if ("MinKey".equals(value)) { + visitEmptyConstructor(); + setCurrentBsonType(BsonType.MIN_KEY); + currentValue = new MinKey(); + } else if ("MaxKey".equals(value)) { + visitEmptyConstructor(); + setCurrentBsonType(BsonType.MAX_KEY); + currentValue = new MaxKey(); + } else if ("BinData".equals(value)) { + setCurrentBsonType(BsonType.BINARY); + currentValue = visitBinDataConstructor(); + } else if ("Date".equals(value)) { + currentValue = visitDateTimeConstructorWithOutNew(); + setCurrentBsonType(BsonType.STRING); + } else if ("HexData".equals(value)) { + setCurrentBsonType(BsonType.BINARY); + currentValue = visitHexDataConstructor(); + } else if ("ISODate".equals(value)) { + setCurrentBsonType(BsonType.DATE_TIME); + currentValue = visitISODateTimeConstructor(); + } else if ("NumberInt".equals(value)) { + setCurrentBsonType(BsonType.INT32); + currentValue = visitNumberIntConstructor(); + } else if ("NumberLong".equals(value)) { + setCurrentBsonType(BsonType.INT64); + currentValue = visitNumberLongConstructor(); + } else if ("NumberDecimal".equals(value)) { + setCurrentBsonType(BsonType.DECIMAL128); + currentValue = visitNumberDecimalConstructor(); + } else if ("ObjectId".equals(value)) { + setCurrentBsonType(BsonType.OBJECT_ID); + currentValue = visitObjectIdConstructor(); + } else if ("Timestamp".equals(value)) { + setCurrentBsonType(BsonType.TIMESTAMP); + currentValue = visitTimestampConstructor(); + } else if ("RegExp".equals(value)) { + setCurrentBsonType(BsonType.REGULAR_EXPRESSION); + currentValue = visitRegularExpressionConstructor(); + } else if ("DBPointer".equals(value)) { + setCurrentBsonType(BsonType.DB_POINTER); + currentValue = visitDBPointerConstructor(); + } else if ("UUID".equals(value) || "GUID".equals(value) || "CSUUID".equals(value) || "CSGUID".equals(value) + || "JUUID".equals(value) || "JGUID".equals(value) || "PYUUID".equals(value) || "PYGUID".equals(value)) { + setCurrentBsonType(BsonType.BINARY); + currentValue = visitUUIDConstructor(value); + } else if ("new".equals(value)) { + visitNew(); + } else { + + bindableValue = bindableValueFor(token); + if (bindableValue != null) { + + if (bindableValue.getIndex() != -1) { + setCurrentBsonType(bindableValue.getType()); + } else { + setCurrentBsonType(BsonType.STRING); + } + + currentValue = bindableValue.getValue(); + } else { + noValueFound = true; + } + } + break; + default: + noValueFound = true; + break; + } + if (noValueFound) { + throw new JsonParseException("JSON reader was expecting a value but found '%s'.", token.getValue()); + } + + if (getContext().getContextType() == BsonContextType.ARRAY + || getContext().getContextType() == BsonContextType.DOCUMENT) { + JsonToken commaToken = popToken(); + if (commaToken.getType() != JsonTokenType.COMMA) { + pushToken(commaToken); + } + } + + switch (getContext().getContextType()) { + case DOCUMENT: + case SCOPE_DOCUMENT: + default: + setState(State.NAME); + break; + case ARRAY: + case JAVASCRIPT_WITH_SCOPE: + case TOP_LEVEL: + setState(State.VALUE); + break; + } + return getCurrentBsonType(); + } + + @Override + public void setState(State newState) { + super.setState(newState); + } + + private BindableValue bindableValueFor(JsonToken token) { + + if (!JsonTokenType.STRING.equals(token.getType()) && !JsonTokenType.UNQUOTED_STRING.equals(token.getType()) + && !JsonTokenType.REGULAR_EXPRESSION.equals(token.getType())) { + return null; + } + + BindableValue bindableValue = new BindableValue(); + String tokenValue = String.class.cast(token.getValue()); + Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(tokenValue); + + if (token.getType().equals(JsonTokenType.UNQUOTED_STRING)) { + + if (matcher.find()) { + + int index = computeParameterIndex(matcher.group()); + bindableValue.setValue(getBindableValueForIndex(index)); + bindableValue.setType(bsonTypeForValue(getBindableValueForIndex(index))); + return bindableValue; + } + + Matcher regexMatcher = EXPRESSION_BINDING_PATTERN.matcher(tokenValue); + if (regexMatcher.find()) { + + String binding = regexMatcher.group(); + String expression = binding.substring(3, binding.length() - 1); + + Object value = evaluateExpression(expression); + bindableValue.setValue(value); + bindableValue.setType(bsonTypeForValue(value)); + return bindableValue; + } + + bindableValue.setValue(tokenValue); + bindableValue.setType(BsonType.STRING); + return bindableValue; + + } + + String computedValue = tokenValue; + while (matcher.find()) { + + String group = matcher.group(); + int index = computeParameterIndex(group); + computedValue = computedValue.replace(group, getBindableValueForIndex(index).toString()); + } + bindableValue.setValue(computedValue); + bindableValue.setType(BsonType.STRING); + + return bindableValue; + } + + private static int computeParameterIndex(String parameter) { + return NumberUtils.parseNumber(parameter.replace("?", ""), Integer.class); + } + + private Object getBindableValueForIndex(int index) { + return bindingContext.bindableValueForIndex(index); + } + + private BsonType bsonTypeForValue(Object value) { + + if (value == null) { + return BsonType.NULL; + } + + Class type = value.getClass(); + + if (ClassUtils.isAssignable(String.class, type)) { + + if (((String) value).startsWith("{")) { + return BsonType.DOCUMENT; + } + return BsonType.STRING; + } + if (ClassUtils.isAssignable(Boolean.class, type)) { + return BsonType.BOOLEAN; + } + if (ClassUtils.isAssignable(Document.class, type)) { + return BsonType.DOCUMENT; + } + if (ClassUtils.isAssignable(Double.class, type)) { + return BsonType.DOUBLE; + } + if (ClassUtils.isAssignable(Long.class, type)) { + return BsonType.INT64; + } + if (ClassUtils.isAssignable(Integer.class, type)) { + return BsonType.INT32; + } + if (ClassUtils.isAssignable(Pattern.class, type)) { + return BsonType.REGULAR_EXPRESSION; + } + if (ClassUtils.isAssignable(Iterable.class, type)) { + return BsonType.ARRAY; + } + + return BsonType.UNDEFINED; + } + + @Nullable + private Object evaluateExpression(String expressionString) { + return bindingContext.evaluateExpression(expressionString); + } + + // CHECKSTYLE:ON + + @Override + public Decimal128 doReadDecimal128() { + return (Decimal128) currentValue; + } + + @Override + protected long doReadDateTime() { + return (Long) currentValue; + } + + @Override + protected double doReadDouble() { + return (Double) currentValue; + } + + @Override + protected void doReadEndArray() { + setContext(getContext().getParentContext()); + + if (getContext().getContextType() == BsonContextType.ARRAY + || getContext().getContextType() == BsonContextType.DOCUMENT) { + JsonToken commaToken = popToken(); + if (commaToken.getType() != JsonTokenType.COMMA) { + pushToken(commaToken); + } + } + } + + @Override + protected void doReadEndDocument() { + setContext(getContext().getParentContext()); + if (getContext() != null && getContext().getContextType() == BsonContextType.SCOPE_DOCUMENT) { + setContext(getContext().getParentContext()); // JavaScriptWithScope + verifyToken(JsonTokenType.END_OBJECT); // outermost closing bracket for JavaScriptWithScope + } + + if (getContext() == null) { + throw new JsonParseException("Unexpected end of document."); + } + + if (getContext().getContextType() == BsonContextType.ARRAY + || getContext().getContextType() == BsonContextType.DOCUMENT) { + JsonToken commaToken = popToken(); + if (commaToken.getType() != JsonTokenType.COMMA) { + pushToken(commaToken); + } + } + } + + @Override + protected int doReadInt32() { + return (Integer) currentValue; + } + + @Override + protected long doReadInt64() { + return (Long) currentValue; + } + + @Override + protected String doReadJavaScript() { + return (String) currentValue; + } + + @Override + protected String doReadJavaScriptWithScope() { + return (String) currentValue; + } + + @Override + protected void doReadMaxKey() {} + + @Override + protected void doReadMinKey() {} + + @Override + protected void doReadNull() {} + + @Override + protected ObjectId doReadObjectId() { + return (ObjectId) currentValue; + } + + @Override + protected BsonRegularExpression doReadRegularExpression() { + return (BsonRegularExpression) currentValue; + } + + @Override + protected BsonDbPointer doReadDBPointer() { + return (BsonDbPointer) currentValue; + } + + @Override + protected void doReadStartArray() { + setContext(new Context(getContext(), BsonContextType.ARRAY)); + } + + @Override + protected void doReadStartDocument() { + setContext(new Context(getContext(), BsonContextType.DOCUMENT)); + } + + @Override + protected String doReadString() { + return (String) currentValue; + } + + @Override + protected String doReadSymbol() { + return (String) currentValue; + } + + @Override + protected BsonTimestamp doReadTimestamp() { + return (BsonTimestamp) currentValue; + } + + @Override + protected void doReadUndefined() {} + + @Override + protected void doSkipName() {} + + @Override + protected void doSkipValue() { + switch (getCurrentBsonType()) { + case ARRAY: + readStartArray(); + while (readBsonType() != BsonType.END_OF_DOCUMENT) { + skipValue(); + } + readEndArray(); + break; + case BINARY: + readBinaryData(); + break; + case BOOLEAN: + readBoolean(); + break; + case DATE_TIME: + readDateTime(); + break; + case DOCUMENT: + readStartDocument(); + while (readBsonType() != BsonType.END_OF_DOCUMENT) { + skipName(); + skipValue(); + } + readEndDocument(); + break; + case DOUBLE: + readDouble(); + break; + case INT32: + readInt32(); + break; + case INT64: + readInt64(); + break; + case DECIMAL128: + readDecimal128(); + break; + case JAVASCRIPT: + readJavaScript(); + break; + case JAVASCRIPT_WITH_SCOPE: + readJavaScriptWithScope(); + readStartDocument(); + while (readBsonType() != BsonType.END_OF_DOCUMENT) { + skipName(); + skipValue(); + } + readEndDocument(); + break; + case MAX_KEY: + readMaxKey(); + break; + case MIN_KEY: + readMinKey(); + break; + case NULL: + readNull(); + break; + case OBJECT_ID: + readObjectId(); + break; + case REGULAR_EXPRESSION: + readRegularExpression(); + break; + case STRING: + readString(); + break; + case SYMBOL: + readSymbol(); + break; + case TIMESTAMP: + readTimestamp(); + break; + case UNDEFINED: + readUndefined(); + break; + default: + } + } + + private JsonToken popToken() { + if (pushedToken != null) { + JsonToken token = pushedToken; + pushedToken = null; + return token; + } else { + return scanner.nextToken(); + } + } + + private void pushToken(final JsonToken token) { + if (pushedToken == null) { + pushedToken = token; + } else { + throw new BsonInvalidOperationException("There is already a pending token."); + } + } + + private void verifyToken(final JsonTokenType expectedType) { + JsonToken token = popToken(); + if (expectedType != token.getType()) { + throw new JsonParseException("JSON reader expected token type '%s' but found '%s'.", expectedType, + token.getValue()); + } + } + + private void verifyToken(final JsonTokenType expectedType, final Object expectedValue) { + JsonToken token = popToken(); + if (expectedType != token.getType()) { + throw new JsonParseException("JSON reader expected token type '%s' but found '%s'.", expectedType, + token.getValue()); + } + if (!expectedValue.equals(token.getValue())) { + throw new JsonParseException("JSON reader expected '%s' but found '%s'.", expectedValue, token.getValue()); + } + } + + private void verifyString(final String expected) { + if (expected == null) { + throw new IllegalArgumentException("Can't be null"); + } + + JsonToken token = popToken(); + JsonTokenType type = token.getType(); + + if ((type != JsonTokenType.STRING && type != JsonTokenType.UNQUOTED_STRING) || !expected.equals(token.getValue())) { + throw new JsonParseException("JSON reader expected '%s' but found '%s'.", expected, token.getValue()); + } + } + + private void visitNew() { + JsonToken typeToken = popToken(); + if (typeToken.getType() != JsonTokenType.UNQUOTED_STRING) { + throw new JsonParseException("JSON reader expected a type name but found '%s'.", typeToken.getValue()); + } + + String value = typeToken.getValue(String.class); + + if ("MinKey".equals(value)) { + visitEmptyConstructor(); + setCurrentBsonType(BsonType.MIN_KEY); + currentValue = new MinKey(); + } else if ("MaxKey".equals(value)) { + visitEmptyConstructor(); + setCurrentBsonType(BsonType.MAX_KEY); + currentValue = new MaxKey(); + } else if ("BinData".equals(value)) { + currentValue = visitBinDataConstructor(); + setCurrentBsonType(BsonType.BINARY); + } else if ("Date".equals(value)) { + currentValue = visitDateTimeConstructor(); + setCurrentBsonType(BsonType.DATE_TIME); + } else if ("HexData".equals(value)) { + currentValue = visitHexDataConstructor(); + setCurrentBsonType(BsonType.BINARY); + } else if ("ISODate".equals(value)) { + currentValue = visitISODateTimeConstructor(); + setCurrentBsonType(BsonType.DATE_TIME); + } else if ("NumberInt".equals(value)) { + currentValue = visitNumberIntConstructor(); + setCurrentBsonType(BsonType.INT32); + } else if ("NumberLong".equals(value)) { + currentValue = visitNumberLongConstructor(); + setCurrentBsonType(BsonType.INT64); + } else if ("NumberDecimal".equals(value)) { + currentValue = visitNumberDecimalConstructor(); + setCurrentBsonType(BsonType.DECIMAL128); + } else if ("ObjectId".equals(value)) { + currentValue = visitObjectIdConstructor(); + setCurrentBsonType(BsonType.OBJECT_ID); + } else if ("RegExp".equals(value)) { + currentValue = visitRegularExpressionConstructor(); + setCurrentBsonType(BsonType.REGULAR_EXPRESSION); + } else if ("DBPointer".equals(value)) { + currentValue = visitDBPointerConstructor(); + setCurrentBsonType(BsonType.DB_POINTER); + } else if ("UUID".equals(value) || "GUID".equals(value) || "CSUUID".equals(value) || "CSGUID".equals(value) + || "JUUID".equals(value) || "JGUID".equals(value) || "PYUUID".equals(value) || "PYGUID".equals(value)) { + currentValue = visitUUIDConstructor(value); + setCurrentBsonType(BsonType.BINARY); + } else { + throw new JsonParseException("JSON reader expected a type name but found '%s'.", value); + } + } + + private void visitExtendedJSON() { + JsonToken nameToken = popToken(); + String value = nameToken.getValue(String.class); + JsonTokenType type = nameToken.getType(); + + if (type == JsonTokenType.STRING || type == JsonTokenType.UNQUOTED_STRING) { + + if ("$binary".equals(value) || "$type".equals(value)) { + currentValue = visitBinDataExtendedJson(value); + if (currentValue != null) { + setCurrentBsonType(BsonType.BINARY); + return; + } + } else if ("$regex".equals(value) || "$options".equals(value)) { + currentValue = visitRegularExpressionExtendedJson(value); + if (currentValue != null) { + setCurrentBsonType(BsonType.REGULAR_EXPRESSION); + return; + } + } else if ("$code".equals(value)) { + visitJavaScriptExtendedJson(); + return; + } else if ("$date".equals(value)) { + currentValue = visitDateTimeExtendedJson(); + setCurrentBsonType(BsonType.DATE_TIME); + return; + } else if ("$maxKey".equals(value)) { + currentValue = visitMaxKeyExtendedJson(); + setCurrentBsonType(BsonType.MAX_KEY); + return; + } else if ("$minKey".equals(value)) { + currentValue = visitMinKeyExtendedJson(); + setCurrentBsonType(BsonType.MIN_KEY); + return; + } else if ("$oid".equals(value)) { + currentValue = visitObjectIdExtendedJson(); + setCurrentBsonType(BsonType.OBJECT_ID); + return; + } else if ("$regularExpression".equals(value)) { + currentValue = visitNewRegularExpressionExtendedJson(); + setCurrentBsonType(BsonType.REGULAR_EXPRESSION); + return; + } else if ("$symbol".equals(value)) { + currentValue = visitSymbolExtendedJson(); + setCurrentBsonType(BsonType.SYMBOL); + return; + } else if ("$timestamp".equals(value)) { + currentValue = visitTimestampExtendedJson(); + setCurrentBsonType(BsonType.TIMESTAMP); + return; + } else if ("$undefined".equals(value)) { + currentValue = visitUndefinedExtendedJson(); + setCurrentBsonType(BsonType.UNDEFINED); + return; + } else if ("$numberLong".equals(value)) { + currentValue = visitNumberLongExtendedJson(); + setCurrentBsonType(BsonType.INT64); + return; + } else if ("$numberInt".equals(value)) { + currentValue = visitNumberIntExtendedJson(); + setCurrentBsonType(BsonType.INT32); + return; + } else if ("$numberDouble".equals(value)) { + currentValue = visitNumberDoubleExtendedJson(); + setCurrentBsonType(BsonType.DOUBLE); + return; + } else if ("$numberDecimal".equals(value)) { + currentValue = visitNumberDecimalExtendedJson(); + setCurrentBsonType(BsonType.DECIMAL128); + return; + } else if ("$dbPointer".equals(value)) { + currentValue = visitDbPointerExtendedJson(); + setCurrentBsonType(BsonType.DB_POINTER); + return; + } + } + + pushToken(nameToken); + setCurrentBsonType(BsonType.DOCUMENT); + } + + private void visitEmptyConstructor() { + JsonToken nextToken = popToken(); + if (nextToken.getType() == JsonTokenType.LEFT_PAREN) { + verifyToken(JsonTokenType.RIGHT_PAREN); + } else { + pushToken(nextToken); + } + } + + private BsonBinary visitBinDataConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + JsonToken subTypeToken = popToken(); + if (subTypeToken.getType() != JsonTokenType.INT32) { + throw new JsonParseException("JSON reader expected a binary subtype but found '%s'.", subTypeToken.getValue()); + } + verifyToken(JsonTokenType.COMMA); + JsonToken bytesToken = popToken(); + if (bytesToken.getType() != JsonTokenType.UNQUOTED_STRING && bytesToken.getType() != JsonTokenType.STRING) { + throw new JsonParseException("JSON reader expected a string but found '%s'.", bytesToken.getValue()); + } + verifyToken(JsonTokenType.RIGHT_PAREN); + + byte[] bytes = Base64.decode(bytesToken.getValue(String.class)); + return new BsonBinary(subTypeToken.getValue(Integer.class).byteValue(), bytes); + } + + private BsonBinary visitUUIDConstructor(final String uuidConstructorName) { + verifyToken(JsonTokenType.LEFT_PAREN); + String hexString = readStringFromExtendedJson().replaceAll("\\{", "").replaceAll("}", "").replaceAll("-", ""); + verifyToken(JsonTokenType.RIGHT_PAREN); + byte[] bytes = decodeHex(hexString); + BsonBinarySubType subType = BsonBinarySubType.UUID_STANDARD; + if (!"UUID".equals(uuidConstructorName) || !"GUID".equals(uuidConstructorName)) { + subType = BsonBinarySubType.UUID_LEGACY; + } + return new BsonBinary(subType, bytes); + } + + private BsonRegularExpression visitRegularExpressionConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + String pattern = readStringFromExtendedJson(); + String options = ""; + JsonToken commaToken = popToken(); + if (commaToken.getType() == JsonTokenType.COMMA) { + options = readStringFromExtendedJson(); + } else { + pushToken(commaToken); + } + verifyToken(JsonTokenType.RIGHT_PAREN); + return new BsonRegularExpression(pattern, options); + } + + private ObjectId visitObjectIdConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + ObjectId objectId = new ObjectId(readStringFromExtendedJson()); + verifyToken(JsonTokenType.RIGHT_PAREN); + return objectId; + } + + private BsonTimestamp visitTimestampConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + JsonToken timeToken = popToken(); + int time; + if (timeToken.getType() != JsonTokenType.INT32) { + throw new JsonParseException("JSON reader expected an integer but found '%s'.", timeToken.getValue()); + } else { + time = timeToken.getValue(Integer.class); + } + verifyToken(JsonTokenType.COMMA); + JsonToken incrementToken = popToken(); + int increment; + if (incrementToken.getType() != JsonTokenType.INT32) { + throw new JsonParseException("JSON reader expected an integer but found '%s'.", timeToken.getValue()); + } else { + increment = incrementToken.getValue(Integer.class); + } + + verifyToken(JsonTokenType.RIGHT_PAREN); + return new BsonTimestamp(time, increment); + } + + private BsonDbPointer visitDBPointerConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + String namespace = readStringFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + ObjectId id = new ObjectId(readStringFromExtendedJson()); + verifyToken(JsonTokenType.RIGHT_PAREN); + return new BsonDbPointer(namespace, id); + } + + private int visitNumberIntConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + JsonToken valueToken = popToken(); + int value; + if (valueToken.getType() == JsonTokenType.INT32) { + value = valueToken.getValue(Integer.class); + } else if (valueToken.getType() == JsonTokenType.STRING) { + value = Integer.parseInt(valueToken.getValue(String.class)); + } else { + throw new JsonParseException("JSON reader expected an integer or a string but found '%s'.", + valueToken.getValue()); + } + verifyToken(JsonTokenType.RIGHT_PAREN); + return value; + } + + private long visitNumberLongConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + JsonToken valueToken = popToken(); + long value; + if (valueToken.getType() == JsonTokenType.INT32 || valueToken.getType() == JsonTokenType.INT64) { + value = valueToken.getValue(Long.class); + } else if (valueToken.getType() == JsonTokenType.STRING) { + value = Long.parseLong(valueToken.getValue(String.class)); + } else { + throw new JsonParseException("JSON reader expected an integer or a string but found '%s'.", + valueToken.getValue()); + } + verifyToken(JsonTokenType.RIGHT_PAREN); + return value; + } + + private Decimal128 visitNumberDecimalConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + JsonToken valueToken = popToken(); + Decimal128 value; + if (valueToken.getType() == JsonTokenType.INT32 || valueToken.getType() == JsonTokenType.INT64 + || valueToken.getType() == JsonTokenType.DOUBLE) { + value = valueToken.getValue(Decimal128.class); + } else if (valueToken.getType() == JsonTokenType.STRING) { + value = Decimal128.parse(valueToken.getValue(String.class)); + } else { + throw new JsonParseException("JSON reader expected a number or a string but found '%s'.", valueToken.getValue()); + } + verifyToken(JsonTokenType.RIGHT_PAREN); + return value; + } + + private long visitISODateTimeConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + + JsonToken token = popToken(); + if (token.getType() == JsonTokenType.RIGHT_PAREN) { + return new Date().getTime(); + } else if (token.getType() != JsonTokenType.STRING) { + throw new JsonParseException("JSON reader expected a string but found '%s'.", token.getValue()); + } + + verifyToken(JsonTokenType.RIGHT_PAREN); + String[] patterns = { "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm:ssz", "yyyy-MM-dd'T'HH:mm:ss.SSSz" }; + + SimpleDateFormat format = new SimpleDateFormat(patterns[0], Locale.ENGLISH); + ParsePosition pos = new ParsePosition(0); + String s = token.getValue(String.class); + + if (s.endsWith("Z")) { + s = s.substring(0, s.length() - 1) + "GMT-00:00"; + } + + for (final String pattern : patterns) { + format.applyPattern(pattern); + format.setLenient(true); + pos.setIndex(0); + + Date date = format.parse(s, pos); + + if (date != null && pos.getIndex() == s.length()) { + return date.getTime(); + } + } + throw new JsonParseException("Invalid date format."); + } + + private BsonBinary visitHexDataConstructor() { + verifyToken(JsonTokenType.LEFT_PAREN); + JsonToken subTypeToken = popToken(); + if (subTypeToken.getType() != JsonTokenType.INT32) { + throw new JsonParseException("JSON reader expected a binary subtype but found '%s'.", subTypeToken.getValue()); + } + verifyToken(JsonTokenType.COMMA); + String hex = readStringFromExtendedJson(); + verifyToken(JsonTokenType.RIGHT_PAREN); + + if ((hex.length() & 1) != 0) { + hex = "0" + hex; + } + + for (final BsonBinarySubType subType : BsonBinarySubType.values()) { + if (subType.getValue() == subTypeToken.getValue(Integer.class)) { + return new BsonBinary(subType, decodeHex(hex)); + } + } + return new BsonBinary(decodeHex(hex)); + } + + private long visitDateTimeConstructor() { + DateFormat format = new SimpleDateFormat("EEE MMM dd yyyy HH:mm:ss z", Locale.ENGLISH); + + verifyToken(JsonTokenType.LEFT_PAREN); + + JsonToken token = popToken(); + if (token.getType() == JsonTokenType.RIGHT_PAREN) { + return new Date().getTime(); + } else if (token.getType() == JsonTokenType.STRING) { + verifyToken(JsonTokenType.RIGHT_PAREN); + String s = token.getValue(String.class); + ParsePosition pos = new ParsePosition(0); + Date dateTime = format.parse(s, pos); + if (dateTime != null && pos.getIndex() == s.length()) { + return dateTime.getTime(); + } else { + throw new JsonParseException( + "JSON reader expected a date in 'EEE MMM dd yyyy HH:mm:ss z' format but found '%s'.", s); + } + + } else if (token.getType() == JsonTokenType.INT32 || token.getType() == JsonTokenType.INT64) { + long[] values = new long[7]; + int pos = 0; + while (true) { + if (pos < values.length) { + values[pos++] = token.getValue(Long.class); + } + token = popToken(); + if (token.getType() == JsonTokenType.RIGHT_PAREN) { + break; + } + if (token.getType() != JsonTokenType.COMMA) { + throw new JsonParseException("JSON reader expected a ',' or a ')' but found '%s'.", token.getValue()); + } + token = popToken(); + if (token.getType() != JsonTokenType.INT32 && token.getType() != JsonTokenType.INT64) { + throw new JsonParseException("JSON reader expected an integer but found '%s'.", token.getValue()); + } + } + if (pos == 1) { + return values[0]; + } else if (pos < 3 || pos > 7) { + throw new JsonParseException("JSON reader expected 1 or 3-7 integers but found %d.", pos); + } + + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + calendar.set(Calendar.YEAR, (int) values[0]); + calendar.set(Calendar.MONTH, (int) values[1]); + calendar.set(Calendar.DAY_OF_MONTH, (int) values[2]); + calendar.set(Calendar.HOUR_OF_DAY, (int) values[3]); + calendar.set(Calendar.MINUTE, (int) values[4]); + calendar.set(Calendar.SECOND, (int) values[5]); + calendar.set(Calendar.MILLISECOND, (int) values[6]); + return calendar.getTimeInMillis(); + } else { + throw new JsonParseException("JSON reader expected an integer or a string but found '%s'.", token.getValue()); + } + } + + private String visitDateTimeConstructorWithOutNew() { + verifyToken(JsonTokenType.LEFT_PAREN); + JsonToken token = popToken(); + if (token.getType() != JsonTokenType.RIGHT_PAREN) { + while (token.getType() != JsonTokenType.END_OF_FILE) { + token = popToken(); + if (token.getType() == JsonTokenType.RIGHT_PAREN) { + break; + } + } + if (token.getType() != JsonTokenType.RIGHT_PAREN) { + throw new JsonParseException("JSON reader expected a ')' but found '%s'.", token.getValue()); + } + } + + DateFormat df = new SimpleDateFormat("EEE MMM dd yyyy HH:mm:ss z", Locale.ENGLISH); + return df.format(new Date()); + } + + private BsonBinary visitBinDataExtendedJson(final String firstKey) { + + Mark mark = new Mark(); + + verifyToken(JsonTokenType.COLON); + + if (firstKey.equals("$binary")) { + JsonToken nextToken = popToken(); + if (nextToken.getType() == JsonTokenType.BEGIN_OBJECT) { + JsonToken nameToken = popToken(); + String firstNestedKey = nameToken.getValue(String.class); + byte[] data; + byte type; + if (firstNestedKey.equals("base64")) { + verifyToken(JsonTokenType.COLON); + data = Base64.decode(readStringFromExtendedJson()); + verifyToken(JsonTokenType.COMMA); + verifyString("subType"); + verifyToken(JsonTokenType.COLON); + type = readBinarySubtypeFromExtendedJson(); + } else if (firstNestedKey.equals("subType")) { + verifyToken(JsonTokenType.COLON); + type = readBinarySubtypeFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("base64"); + verifyToken(JsonTokenType.COLON); + data = Base64.decode(readStringFromExtendedJson()); + } else { + throw new JsonParseException("Unexpected key for $binary: " + firstNestedKey); + } + verifyToken(JsonTokenType.END_OBJECT); + verifyToken(JsonTokenType.END_OBJECT); + return new BsonBinary(type, data); + } else { + mark.reset(); + return visitLegacyBinaryExtendedJson(firstKey); + } + } else { + mark.reset(); + return visitLegacyBinaryExtendedJson(firstKey); + } + } + + private BsonBinary visitLegacyBinaryExtendedJson(final String firstKey) { + + Mark mark = new Mark(); + + try { + verifyToken(JsonTokenType.COLON); + + byte[] data; + byte type; + + if (firstKey.equals("$binary")) { + data = Base64.decode(readStringFromExtendedJson()); + verifyToken(JsonTokenType.COMMA); + verifyString("$type"); + verifyToken(JsonTokenType.COLON); + type = readBinarySubtypeFromExtendedJson(); + } else { + type = readBinarySubtypeFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("$binary"); + verifyToken(JsonTokenType.COLON); + data = Base64.decode(readStringFromExtendedJson()); + } + verifyToken(JsonTokenType.END_OBJECT); + + return new BsonBinary(type, data); + } catch (JsonParseException e) { + mark.reset(); + return null; + } catch (NumberFormatException e) { + mark.reset(); + return null; + } + } + + private byte readBinarySubtypeFromExtendedJson() { + JsonToken subTypeToken = popToken(); + if (subTypeToken.getType() != JsonTokenType.STRING && subTypeToken.getType() != JsonTokenType.INT32) { + throw new JsonParseException("JSON reader expected a string or number but found '%s'.", subTypeToken.getValue()); + } + + if (subTypeToken.getType() == JsonTokenType.STRING) { + return (byte) Integer.parseInt(subTypeToken.getValue(String.class), 16); + } else { + return subTypeToken.getValue(Integer.class).byteValue(); + } + } + + private long visitDateTimeExtendedJson() { + long value; + verifyToken(JsonTokenType.COLON); + JsonToken valueToken = popToken(); + if (valueToken.getType() == JsonTokenType.BEGIN_OBJECT) { + JsonToken nameToken = popToken(); + String name = nameToken.getValue(String.class); + if (!name.equals("$numberLong")) { + throw new JsonParseException( + String.format("JSON reader expected $numberLong within $date, but found %s", name)); + } + value = visitNumberLongExtendedJson(); + verifyToken(JsonTokenType.END_OBJECT); + } else { + if (valueToken.getType() == JsonTokenType.INT32 || valueToken.getType() == JsonTokenType.INT64) { + value = valueToken.getValue(Long.class); + } else if (valueToken.getType() == JsonTokenType.STRING) { + String dateTimeString = valueToken.getValue(String.class); + try { + value = DateTimeFormatter.parse(dateTimeString); + } catch (IllegalArgumentException e) { + throw new JsonParseException("Failed to parse string as a date", e); + } + } else { + throw new JsonParseException("JSON reader expected an integer or string but found '%s'.", + valueToken.getValue()); + } + verifyToken(JsonTokenType.END_OBJECT); + } + return value; + } + + private MaxKey visitMaxKeyExtendedJson() { + verifyToken(JsonTokenType.COLON); + verifyToken(JsonTokenType.INT32, 1); + verifyToken(JsonTokenType.END_OBJECT); + return new MaxKey(); + } + + private MinKey visitMinKeyExtendedJson() { + verifyToken(JsonTokenType.COLON); + verifyToken(JsonTokenType.INT32, 1); + verifyToken(JsonTokenType.END_OBJECT); + return new MinKey(); + } + + private ObjectId visitObjectIdExtendedJson() { + verifyToken(JsonTokenType.COLON); + ObjectId objectId = new ObjectId(readStringFromExtendedJson()); + verifyToken(JsonTokenType.END_OBJECT); + return objectId; + } + + private BsonRegularExpression visitNewRegularExpressionExtendedJson() { + verifyToken(JsonTokenType.COLON); + verifyToken(JsonTokenType.BEGIN_OBJECT); + + String pattern; + String options = ""; + + String firstKey = readStringFromExtendedJson(); + if (firstKey.equals("pattern")) { + verifyToken(JsonTokenType.COLON); + pattern = readStringFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("options"); + verifyToken(JsonTokenType.COLON); + options = readStringFromExtendedJson(); + } else if (firstKey.equals("options")) { + verifyToken(JsonTokenType.COLON); + options = readStringFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("pattern"); + verifyToken(JsonTokenType.COLON); + pattern = readStringFromExtendedJson(); + } else { + throw new JsonParseException("Expected 't' and 'i' fields in $timestamp document but found " + firstKey); + } + + verifyToken(JsonTokenType.END_OBJECT); + verifyToken(JsonTokenType.END_OBJECT); + return new BsonRegularExpression(pattern, options); + } + + private BsonRegularExpression visitRegularExpressionExtendedJson(final String firstKey) { + Mark extendedJsonMark = new Mark(); + + try { + verifyToken(JsonTokenType.COLON); + + String pattern; + String options = ""; + if (firstKey.equals("$regex")) { + pattern = readStringFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("$options"); + verifyToken(JsonTokenType.COLON); + options = readStringFromExtendedJson(); + } else { + options = readStringFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("$regex"); + verifyToken(JsonTokenType.COLON); + pattern = readStringFromExtendedJson(); + } + verifyToken(JsonTokenType.END_OBJECT); + return new BsonRegularExpression(pattern, options); + } catch (JsonParseException e) { + extendedJsonMark.reset(); + return null; + } + } + + private String readStringFromExtendedJson() { + JsonToken patternToken = popToken(); + if (patternToken.getType() != JsonTokenType.STRING) { + throw new JsonParseException("JSON reader expected a string but found '%s'.", patternToken.getValue()); + } + + return bindableValueFor(patternToken).getValue().toString(); + } + + private String visitSymbolExtendedJson() { + verifyToken(JsonTokenType.COLON); + String symbol = readStringFromExtendedJson(); + verifyToken(JsonTokenType.END_OBJECT); + return symbol; + } + + private BsonTimestamp visitTimestampExtendedJson() { + verifyToken(JsonTokenType.COLON); + verifyToken(JsonTokenType.BEGIN_OBJECT); + + int time; + int increment; + + String firstKey = readStringFromExtendedJson(); + if (firstKey.equals("t")) { + verifyToken(JsonTokenType.COLON); + time = readIntFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("i"); + verifyToken(JsonTokenType.COLON); + increment = readIntFromExtendedJson(); + } else if (firstKey.equals("i")) { + verifyToken(JsonTokenType.COLON); + increment = readIntFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("t"); + verifyToken(JsonTokenType.COLON); + time = readIntFromExtendedJson(); + } else { + throw new JsonParseException("Expected 't' and 'i' fields in $timestamp document but found " + firstKey); + } + + verifyToken(JsonTokenType.END_OBJECT); + verifyToken(JsonTokenType.END_OBJECT); + return new BsonTimestamp(time, increment); + } + + private int readIntFromExtendedJson() { + JsonToken nextToken = popToken(); + int value; + if (nextToken.getType() == JsonTokenType.INT32) { + value = nextToken.getValue(Integer.class); + } else if (nextToken.getType() == JsonTokenType.INT64) { + value = nextToken.getValue(Long.class).intValue(); + } else { + throw new JsonParseException("JSON reader expected an integer but found '%s'.", nextToken.getValue()); + } + return value; + } + + private void visitJavaScriptExtendedJson() { + verifyToken(JsonTokenType.COLON); + String code = readStringFromExtendedJson(); + JsonToken nextToken = popToken(); + switch (nextToken.getType()) { + case COMMA: + verifyString("$scope"); + verifyToken(JsonTokenType.COLON); + setState(State.VALUE); + currentValue = code; + setCurrentBsonType(BsonType.JAVASCRIPT_WITH_SCOPE); + setContext(new Context(getContext(), BsonContextType.SCOPE_DOCUMENT)); + break; + case END_OBJECT: + currentValue = code; + setCurrentBsonType(BsonType.JAVASCRIPT); + break; + default: + throw new JsonParseException("JSON reader expected ',' or '}' but found '%s'.", nextToken); + } + } + + private BsonUndefined visitUndefinedExtendedJson() { + verifyToken(JsonTokenType.COLON); + JsonToken valueToken = popToken(); + if (!valueToken.getValue(String.class).equals("true")) { + throw new JsonParseException("JSON reader requires $undefined to have the value of true but found '%s'.", + valueToken.getValue()); + } + verifyToken(JsonTokenType.END_OBJECT); + return new BsonUndefined(); + } + + private Long visitNumberLongExtendedJson() { + verifyToken(JsonTokenType.COLON); + Long value; + String longAsString = readStringFromExtendedJson(); + try { + value = Long.valueOf(longAsString); + } catch (NumberFormatException e) { + throw new JsonParseException( + format("Exception converting value '%s' to type %s", longAsString, Long.class.getName()), e); + } + verifyToken(JsonTokenType.END_OBJECT); + return value; + } + + private Integer visitNumberIntExtendedJson() { + verifyToken(JsonTokenType.COLON); + Integer value; + String intAsString = readStringFromExtendedJson(); + try { + value = Integer.valueOf(intAsString); + } catch (NumberFormatException e) { + throw new JsonParseException( + format("Exception converting value '%s' to type %s", intAsString, Integer.class.getName()), e); + } + verifyToken(JsonTokenType.END_OBJECT); + return value; + } + + private Double visitNumberDoubleExtendedJson() { + verifyToken(JsonTokenType.COLON); + Double value; + String doubleAsString = readStringFromExtendedJson(); + try { + value = Double.valueOf(doubleAsString); + } catch (NumberFormatException e) { + throw new JsonParseException( + format("Exception converting value '%s' to type %s", doubleAsString, Double.class.getName()), e); + } + verifyToken(JsonTokenType.END_OBJECT); + return value; + } + + private Decimal128 visitNumberDecimalExtendedJson() { + verifyToken(JsonTokenType.COLON); + Decimal128 value; + String decimal128AsString = readStringFromExtendedJson(); + try { + value = Decimal128.parse(decimal128AsString); + } catch (NumberFormatException e) { + throw new JsonParseException( + format("Exception converting value '%s' to type %s", decimal128AsString, Decimal128.class.getName()), e); + } + verifyToken(JsonTokenType.END_OBJECT); + return value; + } + + private BsonDbPointer visitDbPointerExtendedJson() { + verifyToken(JsonTokenType.COLON); + verifyToken(JsonTokenType.BEGIN_OBJECT); + + String ref; + ObjectId oid; + + String firstKey = readStringFromExtendedJson(); + if (firstKey.equals("$ref")) { + verifyToken(JsonTokenType.COLON); + ref = readStringFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("$id"); + oid = readDbPointerIdFromExtendedJson(); + verifyToken(JsonTokenType.END_OBJECT); + } else if (firstKey.equals("$id")) { + oid = readDbPointerIdFromExtendedJson(); + verifyToken(JsonTokenType.COMMA); + verifyString("$ref"); + verifyToken(JsonTokenType.COLON); + ref = readStringFromExtendedJson(); + + } else { + throw new JsonParseException("Expected $ref and $id fields in $dbPointer document but found " + firstKey); + } + verifyToken(JsonTokenType.END_OBJECT); + return new BsonDbPointer(ref, oid); + } + + private ObjectId readDbPointerIdFromExtendedJson() { + ObjectId oid; + verifyToken(JsonTokenType.COLON); + verifyToken(JsonTokenType.BEGIN_OBJECT); + verifyToken(JsonTokenType.STRING, "$oid"); + oid = visitObjectIdExtendedJson(); + return oid; + } + + @Deprecated + @Override + public void mark() { + if (mark != null) { + throw new BSONException("A mark already exists; it needs to be reset before creating a new one"); + } + mark = new Mark(); + } + + @Override + public BsonReaderMark getMark() { + return new Mark(); + } + + @Deprecated + @Override + public void reset() { + if (mark == null) { + throw new BSONException("trying to reset a mark before creating it"); + } + mark.reset(); + mark = null; + } + + @Override + protected Context getContext() { + return (Context) super.getContext(); + } + + protected class Mark extends AbstractBsonReader.Mark { + private final JsonToken pushedToken; + private final Object currentValue; + private final int position; + + protected Mark() { + super(); + pushedToken = ParameterBindingJsonReader.this.pushedToken; + currentValue = ParameterBindingJsonReader.this.currentValue; + position = ParameterBindingJsonReader.this.scanner.getBufferPosition(); + } + + public void reset() { + super.reset(); + ParameterBindingJsonReader.this.pushedToken = pushedToken; + ParameterBindingJsonReader.this.currentValue = currentValue; + ParameterBindingJsonReader.this.scanner.setBufferPosition(position); + ParameterBindingJsonReader.this.setContext(new Context(getParentContext(), getContextType())); + } + } + + protected class Context extends AbstractBsonReader.Context { + protected Context(final AbstractBsonReader.Context parentContext, final BsonContextType contextType) { + super(parentContext, contextType); + } + + protected Context getParentContext() { + return (Context) super.getParentContext(); + } + + protected BsonContextType getContextType() { + return super.getContextType(); + } + } + + private static byte[] decodeHex(final String hex) { + if (hex.length() % 2 != 0) { + throw new IllegalArgumentException("A hex string must contain an even number of characters: " + hex); + } + + byte[] out = new byte[hex.length() / 2]; + + for (int i = 0; i < hex.length(); i += 2) { + int high = Character.digit(hex.charAt(i), 16); + int low = Character.digit(hex.charAt(i + 1), 16); + if (high == -1 || low == -1) { + throw new IllegalArgumentException("A hex string can only contain the characters 0-9, A-F, a-f: " + hex); + } + + out[i / 2] = (byte) (high * 16 + low); + } + + return out; + } + + @Data + static class BindableValue { + + BsonType type; + Object value; + int index; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ValueProvider.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ValueProvider.java new file mode 100644 index 000000000..9e70dc7b9 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ValueProvider.java @@ -0,0 +1,36 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import org.springframework.lang.Nullable; + +/** + * A value provider to retrieve bindable values by their parameter index. + * + * @author Christoph Strobl + * @since 2.2 + */ +@FunctionalInterface +public interface ValueProvider { + + /** + * @param index parameter index to use. + * @return can be {@literal null}. + * @throws RuntimeException if the requested element does not exist. + */ + @Nullable + Object getBindableValue(int index); +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQueryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQueryUnitTests.java index e54243260..92c3fe8fb 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQueryUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedMongoQueryUnitTests.java @@ -327,7 +327,7 @@ public class StringBasedMongoQueryUnitTests { byte[] binaryData = "Matthews".getBytes(StandardCharsets.UTF_8); ConvertingParameterAccessor accessor = StubParameterAccessor.getAccessor(converter, - (Object) Arrays.asList(binaryData)); + (Object) Collections.singletonList(binaryData)); StringBasedMongoQuery mongoQuery = createQueryForMethod("findByLastnameAsBinaryIn", List.class); org.springframework.data.mongodb.core.query.Query query = mongoQuery.createQuery(accessor); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReaderUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReaderUnitTests.java new file mode 100644 index 000000000..8f5061b83 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReaderUnitTests.java @@ -0,0 +1,185 @@ +/* + * Copyright 2019. the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.util.json; + +import static org.assertj.core.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.bson.Document; +import org.bson.codecs.DecoderContext; +import org.junit.Test; + +/** + * @author Christoph Strobl + * @since 2019/02 + */ +public class ParameterBindingJsonReaderUnitTests { + + @Test + public void bindUnquotedStringValue() { + + Document target = parse("{ 'lastname' : ?0 }", "kohlin"); + assertThat(target).isEqualTo(new Document("lastname", "kohlin")); + } + + @Test + public void bindQuotedStringValue() { + + Document target = parse("{ 'lastname' : '?0' }", "kohlin"); + assertThat(target).isEqualTo(new Document("lastname", "kohlin")); + } + + @Test + public void bindUnquotedIntegerValue() { + + Document target = parse("{ 'lastname' : ?0 } ", 100); + assertThat(target).isEqualTo(new Document("lastname", 100)); + } + + @Test + public void bindMultiplePlacholders() { + + Document target = parse("{ 'lastname' : ?0, 'firstname' : '?1' }", "Kohlin", "Dalinar"); + assertThat(target).isEqualTo(Document.parse("{ 'lastname' : 'Kohlin', 'firstname' : 'Dalinar' }")); + } + + @Test + public void bindQuotedIntegerValue() { + + Document target = parse("{ 'lastname' : '?0' }", 100); + assertThat(target).isEqualTo(new Document("lastname", "100")); + } + + @Test + public void bindValueToRegex() { + + Document target = parse("{ 'lastname' : { '$regex' : '^(?0)'} }", "kohlin"); + assertThat(target).isEqualTo(Document.parse("{ 'lastname' : { '$regex' : '^(kohlin)'} }")); + } + + @Test + public void bindValueToMultiRegex() { + + Document target = parse( + "{'$or' : [{'firstname': {'$regex': '.*?0.*', '$options': 'i'}}, {'lastname' : {'$regex': '.*?0xyz.*', '$options': 'i'}} ]}", + "calamity"); + assertThat(target).isEqualTo(Document.parse( + "{ \"$or\" : [ { \"firstname\" : { \"$regex\" : \".*calamity.*\" , \"$options\" : \"i\"}} , { \"lastname\" : { \"$regex\" : \".*calamityxyz.*\" , \"$options\" : \"i\"}}]}")); + } + + @Test + public void bindMultipleValuesToSingleToken() { + + Document target = parse("{$where: 'return this.date.getUTCMonth() == ?2 && this.date.getUTCDay() == ?3;'}", 0, 1, 2, + 3, 4); + assertThat(target) + .isEqualTo(Document.parse("{$where: 'return this.date.getUTCMonth() == 2 && this.date.getUTCDay() == 3;'}")); + } + + @Test + public void bindValueToDbRef() { + + Document target = parse("{ 'reference' : { $ref : 'reference', $id : ?0 }}", "kohlin"); + assertThat(target).isEqualTo(Document.parse("{ 'reference' : { $ref : 'reference', $id : 'kohlin' }}")); + } + + @Test + public void bindToKey() { + + Document target = parse("{ ?0 : ?1 }", "firstname", "kaladin"); + assertThat(target).isEqualTo(Document.parse("{ 'firstname' : 'kaladin' }")); + } + + @Test + public void bindListValue() { + + // + Document target = parse("{ 'lastname' : { $in : ?0 } }", Arrays.asList("Kohlin", "Davar")); + assertThat(target).isEqualTo(Document.parse("{ 'lastname' : { $in : ['Kohlin', 'Davar' ]} }")); + } + + @Test + public void bindListOfBinaryValue() { + + // + byte[] value = "Kohlin".getBytes(StandardCharsets.UTF_8); + List args = Collections.singletonList(value); + + Document target = parse("{ 'lastname' : { $in : ?0 } }",args); + assertThat(target).isEqualTo(new Document("lastname", new Document("$in", args))); + } + + @Test + public void bindExtendedExpression() { + + Document target = parse("{'id':?#{ [0] ? { $exists :true} : [1] }}", true, "firstname", "kaladin"); + assertThat(target).isEqualTo(Document.parse("{ \"id\" : { \"$exists\" : true}}")); + } + + // {'id':?#{ [0] ? { $exists :true} : [1] }} + + @Test + public void bindDocumentValue() { + + // + Document target = parse("{ 'lastname' : ?0 }", new Document("$eq", "Kohlin")); + assertThat(target).isEqualTo(Document.parse("{ 'lastname' : { '$eq' : 'Kohlin' } }")); + } + + @Test + public void arrayWithoutBinding() { + + // + Document target = parse("{ 'lastname' : { $in : [\"Kohlin\", \"Davar\"] } }"); + assertThat(target).isEqualTo(Document.parse("{ 'lastname' : { $in : ['Kohlin', 'Davar' ]} }")); + } + + @Test + public void bindSpEL() { + + // "{ arg0 : ?#{[0]} }" + Document target = parse("{ arg0 : ?#{[0]} }", 100.01D); + assertThat(target).isEqualTo(new Document("arg0", 100.01D)); + } + + private static Document parse(String json, Object... args) { + + ParameterBindingJsonReader reader = new ParameterBindingJsonReader(json, args); + return new ParameterBindingDocumentCodec().decode(reader, + DecoderContext.builder().build()); + } + +}