Skip to content

Commit

Permalink
Implement $min and $max update operators (#233)
Browse files Browse the repository at this point in the history
  • Loading branch information
tatu-at-datastax authored Mar 9, 2023
1 parent e3a4f54 commit 20f48ff
Show file tree
Hide file tree
Showing 6 changed files with 672 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.stargate.sgv2.jsonapi.api.model.command.clause.update;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.stargate.sgv2.jsonapi.util.JsonNodeComparator;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
* Implementation of {@code $min} and {@code $max} update operation used to modify numeric field
* values in documents. See {@href
* https://www.mongodb.com/docs/manual/reference/operator/update/min/} and {@href
* https://www.mongodb.com/docs/manual/reference/operator/update/max/} for full explanations.
*/
public class MinMaxOperation extends UpdateOperation {
private final List<MinMaxAction> actions;

private final boolean isMaxAction;

private MinMaxOperation(boolean isMaxAction, List<MinMaxAction> actions) {
this.isMaxAction = isMaxAction;
this.actions = sortByPath(actions);
}

public static MinMaxOperation constructMax(ObjectNode args) {
return construct(args, UpdateOperator.MAX, true);
}

public static MinMaxOperation constructMin(ObjectNode args) {
return construct(args, UpdateOperator.MIN, false);
}

private static MinMaxOperation construct(ObjectNode args, UpdateOperator oper, boolean isMax) {
Iterator<Map.Entry<String, JsonNode>> fieldIter = args.fields();

List<MinMaxAction> actions = new ArrayList<>();
while (fieldIter.hasNext()) {
Map.Entry<String, JsonNode> entry = fieldIter.next();
// Verify we do not try to change doc id
String path = validateUpdatePath(oper, entry.getKey());
actions.add(new MinMaxAction(path, entry.getValue()));
}
return new MinMaxOperation(isMax, actions);
}

@Override
public boolean updateDocument(ObjectNode doc, UpdateTargetLocator targetLocator) {
// Almost always changes, except if adding zero; need to track
boolean modified = false;
for (MinMaxAction action : actions) {
final String path = action.path;
final JsonNode value = action.value;

UpdateTarget target = targetLocator.findOrCreate(doc, path);
JsonNode oldValue = target.valueNode();

if (oldValue == null) { // No such property? Add value
target.replaceValue(value);
modified = true;
} else { // Otherwise, need to see if less-than (min) or greater-than (max)
if (shouldReplace(oldValue, value)) {
target.replaceValue(value);
modified = true;
}
}
}

return modified;
}

private boolean shouldReplace(JsonNode oldValue, JsonNode newValue) {
if (isMaxAction) {
// For $max, replace if newValue sorts later
return JsonNodeComparator.ascending().compare(oldValue, newValue) < 0;
}
// For $min, replace if newValue sorts earlier
return JsonNodeComparator.ascending().compare(oldValue, newValue) > 0;
}

/** Value class for per-field update operations. */
private record MinMaxAction(String path, JsonNode value) implements ActionWithPath {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ public UpdateOperation resolveOperation(ObjectNode arguments) {
}
},

MAX("$max") {
@Override
public UpdateOperation resolveOperation(ObjectNode arguments) {
return MinMaxOperation.constructMax(arguments);
}
},

MIN("$min") {
@Override
public UpdateOperation resolveOperation(ObjectNode arguments) {
return MinMaxOperation.constructMin(arguments);
}
},

POP("$pop") {
@Override
public UpdateOperation resolveOperation(ObjectNode arguments) {
Expand Down
152 changes: 152 additions & 0 deletions src/main/java/io/stargate/sgv2/jsonapi/util/JsonNodeComparator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package io.stargate.sgv2.jsonapi.util;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.Iterator;
import java.util.Map;

/**
* {@link Comparator} for sorting {@link JsonNode} values as needed for operations like {@code $min}
* and {@code $max}. Uses definitions of BSON type-based sorting, where order of types is (from
* lowest to highest precedence):
*
* <ol>
* <li>Null
* <li>Number
* <li>String
* <li>Object
* <li>Array
* <li>Boolean
* </oL>
*
* (NOTE: these are types we have -- MongoDB has more native types so this is a subset of BSON
* sorting definitions).
*
* <p>Within each type sorting is as usual for most types (Numbers, Strings, Booleans). Arrays use
* straight-forward element-by-element sorting (similar to Strings). The only more esoteric case are
* Objects, where sorting is by ordered fields, first comparing field name (String sort), if same,
* then recursively by value; and if first N fields the same, Object with more properties is sorted
* last.
*/
public class JsonNodeComparator implements Comparator<JsonNode> {
private static final Comparator<JsonNode> ASC = new JsonNodeComparator();

private static final Comparator<JsonNode> DESC = ASC.reversed();

public static Comparator<JsonNode> ascending() {
return ASC;
}

public static Comparator<JsonNode> descending() {
return DESC;
}

@Override
public int compare(JsonNode o1, JsonNode o2) {
JsonNodeType type1 = o1.getNodeType();
JsonNodeType type2 = o2.getNodeType();

// If value types differ, base on type precedence as per Mongo specs:
if (type1 != type2) {
return typePriority(type1) - typePriority(type2);
}

switch (type1) {
case NULL:
return 0; // nulls are same so...
case NUMBER:
return compareNumbers(o1.decimalValue(), o2.decimalValue());
case STRING:
return compareStrings(o1.textValue(), o2.textValue());
case OBJECT:
return compareObjects((ObjectNode) o1, (ObjectNode) o2);
case ARRAY:
return compareArrays((ArrayNode) o1, (ArrayNode) o2);
case BOOLEAN:
return compareBooleans(o1.booleanValue(), o2.booleanValue());
default:
// Should never happen:
throw new IllegalStateException("Unsupported JsonNodeType for comparison: " + type1);
}
}

private int compareBooleans(boolean b1, boolean b2) {
if (b1 == b2) {
return 0;
}
return b1 ? 1 : -1;
}

private int compareNumbers(BigDecimal n1, BigDecimal n2) {
return n1.compareTo(n2);
}

private int compareStrings(String n1, String n2) {
return n1.compareTo(n2);
}

private int compareArrays(ArrayNode n1, ArrayNode n2) {
final int len1 = n1.size();
final int len2 = n2.size();

// First: compare first N entries that are common
for (int i = 0, end = Math.min(len1, len2); i < end; ++i) {
int diff = compare(n1.get(i), n2.get(i));
if (diff != 0) {
return diff;
}
}

// and if no difference, longer Array has higher precedence
return len1 - len2;
}

private int compareObjects(ObjectNode n1, ObjectNode n2) {
// Object comparison is interesting: compares entries in order,
// first by property name, then by value. If all else equal, "longer one wins"
Iterator<Map.Entry<String, JsonNode>> it1 = n1.fields();
Iterator<Map.Entry<String, JsonNode>> it2 = n2.fields();

while (it1.hasNext() && it2.hasNext()) {
Map.Entry<String, JsonNode> entry1 = it1.next();
Map.Entry<String, JsonNode> entry2 = it2.next();

// First, key:
int diff = entry1.getKey().compareTo(entry2.getKey());
if (diff == 0) {
// If key same, then value
diff = compare(entry1.getValue(), entry2.getValue());
if (diff == 0) {
continue;
}
}
return diff;
}

// Longer one wins, otherwise
return n1.size() - n2.size();
}

private int typePriority(JsonNodeType type) {
switch (type) {
case NULL:
return 0;
case NUMBER:
return 1;
case STRING:
return 2;
case OBJECT:
return 3;
case ARRAY:
return 4;
case BOOLEAN:
return 5;
default:
return 6;
}
}
}
Loading

0 comments on commit 20f48ff

Please sign in to comment.