diff --git a/pom.xml b/pom.xml index a8d772b79198ebb194fea01de82766addcfb390b..4953adf256ccf345c4b9d874566b0f7c46efc584 100644 --- a/pom.xml +++ b/pom.xml @@ -149,7 +149,7 @@ <dependency> <groupId>com.floragunn</groupId> <artifactId>fluent-collections</artifactId> - <version>0.2.1</version> + <version>0.2.2</version> </dependency> <dependency> diff --git a/src/main/java/com/floragunn/codova/documents/DocNode.java b/src/main/java/com/floragunn/codova/documents/DocNode.java index 2d34d146190c9d7e93e140b70bea2d7d23e38e53..21df8c228c771256093d74af7b744158d7a2aaa5 100644 --- a/src/main/java/com/floragunn/codova/documents/DocNode.java +++ b/src/main/java/com/floragunn/codova/documents/DocNode.java @@ -670,6 +670,10 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> { } } + public static DocNode array(Object... array) { + return new PlainJavaObjectAdapter(ImmutableList.ofArray(array)); + } + private static void add(OrderedImmutableMap.Builder<String, Object> builder, String key, Object value) { int s = key.indexOf('.'); @@ -1075,6 +1079,7 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> { return mapBuilder; } + } protected String key; @@ -1093,6 +1098,10 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> { public abstract boolean isList(String attribute); + public boolean isScalar() { + return !isMap() && !isList() && !isNull(); + } + public abstract ImmutableList<Object> toList(); public abstract DocNode splitDottedAttributeNamesToTree() throws UnexpectedDocumentStructureException; @@ -1151,6 +1160,10 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> { return new PlainJavaObjectAdapter(this.toMap().with(otherDocNode.toMap())); } + public DocNode with(String key, Object value) { + return new PlainJavaObjectAdapter(this.toMap().with(key, value)); + } + public DocNode without(String... attrs) { Set<String> attrsSet = new HashSet<>(Arrays.asList(attrs)); diff --git a/src/main/java/com/floragunn/codova/documents/DocReader.java b/src/main/java/com/floragunn/codova/documents/DocReader.java index d2072072e2984e636a859c29842bad547c038902..0d3cf310652330b133d629f41812bfbc4b6f2d2e 100644 --- a/src/main/java/com/floragunn/codova/documents/DocReader.java +++ b/src/main/java/com/floragunn/codova/documents/DocReader.java @@ -216,10 +216,8 @@ public class DocReader { container.put(attributePath[attributePath.length - 1], newNode); } else { - if (handleDottedAttributeNamesAsPathsFromDepth != -1 && handleDottedAttributeNamesAsPathsFromDepth <= depth - && map.containsKey(currentAttributeName)) { - throw new JsonParseException(parser, "Badly nested nodes: The key " + currentAttributeName - + " is already defined as a non-map object: " + map.get(currentAttributeName)); + if (map.containsKey(currentAttributeName)) { + throw new JsonParseException(parser, "The attribute '" + currentAttributeName + "' is defined more than once"); } map.put(currentAttributeName, newNode); diff --git a/src/main/java/com/floragunn/codova/documents/DocUpdateException.java b/src/main/java/com/floragunn/codova/documents/DocUpdateException.java new file mode 100644 index 0000000000000000000000000000000000000000..451e4683b61782244faf4ef5dc889c72bbb3cc64 --- /dev/null +++ b/src/main/java/com/floragunn/codova/documents/DocUpdateException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.floragunn.codova.documents; + +public class DocUpdateException extends Exception { + + private static final long serialVersionUID = -181998416660817945L; + + public DocUpdateException(String message, Throwable cause) { + super(message, cause); + } + + public DocUpdateException(String message) { + super(message); + } +} diff --git a/src/main/java/com/floragunn/codova/documents/DocumentParseException.java b/src/main/java/com/floragunn/codova/documents/DocumentParseException.java index 59634cf0bd611deba1f94fec651ac4fb6dd01577..9b523a98dc82f89664ec6adeb88d8946c5b31cf7 100644 --- a/src/main/java/com/floragunn/codova/documents/DocumentParseException.java +++ b/src/main/java/com/floragunn/codova/documents/DocumentParseException.java @@ -1,3 +1,20 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + package com.floragunn.codova.documents; import java.util.LinkedHashMap; diff --git a/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java b/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java index 40d164676d83940357bf7dd2f76bfad16ed3930d..d941ffebd0c3ac03f1e40ddaba18f433bb1cbbba 100644 --- a/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java +++ b/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java @@ -18,6 +18,7 @@ package com.floragunn.codova.documents.patch; import com.floragunn.codova.documents.DocNode; +import com.floragunn.codova.documents.DocUpdateException; import com.floragunn.codova.documents.Format; import com.floragunn.codova.documents.Document; import com.floragunn.codova.documents.UnparsedDocument; @@ -25,13 +26,15 @@ import com.floragunn.codova.validation.ConfigValidationException; import com.floragunn.codova.validation.errors.ValidationError; public interface DocPatch extends Document<Object> { - DocNode apply(DocNode targetDocument); + DocNode apply(DocNode targetDocument) throws DocUpdateException; String getMediaType(); static DocPatch parse(String contentType, String content) throws ConfigValidationException { if (contentType.equalsIgnoreCase(MergePatch.MEDIA_TYPE)) { return new MergePatch(DocNode.parse(Format.JSON).from(content)); + } else if (contentType.equalsIgnoreCase(JsonPatch.MEDIA_TYPE)) { + return new JsonPatch(DocNode.parse(Format.JSON).from(content)); } else if (contentType.equalsIgnoreCase(JsonPathPatch.MEDIA_TYPE)) { return new JsonPathPatch(DocNode.parse(Format.JSON).from(content)); } else if (contentType.equalsIgnoreCase(SimplePathPatch.MEDIA_TYPE)) { @@ -44,10 +47,12 @@ public interface DocPatch extends Document<Object> { static DocPatch parse(UnparsedDocument<?> unparsedDoc) throws ConfigValidationException { if (unparsedDoc.getMediaType().equalsIgnoreCase(MergePatch.MEDIA_TYPE)) { return new MergePatch(DocNode.parse(unparsedDoc)); + } else if (unparsedDoc.getMediaType().equalsIgnoreCase(JsonPatch.MEDIA_TYPE)) { + return new JsonPatch(DocNode.parse(unparsedDoc)); } else if (unparsedDoc.getMediaType().equalsIgnoreCase(JsonPathPatch.MEDIA_TYPE)) { return new JsonPathPatch(DocNode.parse(unparsedDoc)); } else if (unparsedDoc.getMediaType().equalsIgnoreCase(SimplePathPatch.MEDIA_TYPE)) { - return new SimplePathPatch(DocNode.parse(unparsedDoc)); + return new SimplePathPatch(DocNode.parse(unparsedDoc)); } else { throw new ConfigValidationException(new ValidationError(null, "Unsupported patch type: " + unparsedDoc.getMediaType())); } @@ -58,6 +63,8 @@ public interface DocPatch extends Document<Object> { if (contentType.equalsIgnoreCase(MergePatch.MEDIA_TYPE)) { return new MergePatch(docNode.getAsNode("content")); + } else if (contentType.equalsIgnoreCase(JsonPatch.MEDIA_TYPE)) { + return new JsonPatch(docNode.getAsNode("content")); } else if (contentType.equalsIgnoreCase(JsonPathPatch.MEDIA_TYPE)) { return new JsonPathPatch(docNode.getAsNode("content")); } else if (contentType.equalsIgnoreCase(SimplePathPatch.MEDIA_TYPE)) { diff --git a/src/main/java/com/floragunn/codova/documents/patch/JsonPatch.java b/src/main/java/com/floragunn/codova/documents/patch/JsonPatch.java new file mode 100644 index 0000000000000000000000000000000000000000..9e63245b378163e075d63e980efdde733cd1012c --- /dev/null +++ b/src/main/java/com/floragunn/codova/documents/patch/JsonPatch.java @@ -0,0 +1,449 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.floragunn.codova.documents.patch; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import com.floragunn.codova.documents.DocNode; +import com.floragunn.codova.documents.DocUpdateException; +import com.floragunn.codova.documents.Document; +import com.floragunn.codova.documents.pointer.JsonPointer; +import com.floragunn.codova.documents.pointer.PointerEvaluationException; +import com.floragunn.codova.validation.ConfigValidationException; +import com.floragunn.codova.validation.ValidatingDocNode; +import com.floragunn.codova.validation.ValidationErrors; +import com.floragunn.codova.validation.errors.InvalidAttributeValue; +import com.floragunn.fluent.collections.ImmutableList; +import com.floragunn.fluent.collections.OrderedImmutableMap; + +public class JsonPatch implements DocPatch { + public static final String MEDIA_TYPE = "application/json-patch+json"; + + private ImmutableList<Operation> operations; + + public JsonPatch(Operation... operations) { + this.operations = ImmutableList.ofArray(operations); + } + + JsonPatch(ImmutableList<Operation> operations) { + this.operations = operations; + } + + JsonPatch(DocNode source) throws ConfigValidationException { + if (source.isList()) { + ValidationErrors validationErrors = new ValidationErrors(); + + int i = 0; + + ArrayList<Operation> operations = new ArrayList<>(); + + for (DocNode element : source.toListOfNodes()) { + try { + operations.add(Operation.parse(element)); + } catch (ConfigValidationException e) { + validationErrors.add(String.valueOf(i), e); + } + i++; + } + validationErrors.throwExceptionForPresentErrors(); + this.operations = ImmutableList.of(operations); + } else { + throw new ConfigValidationException(new InvalidAttributeValue(null, source, "An array of operations")); + } + } + + @Override + public Object toBasicObject() { + return operations; + } + + @Override + public DocNode apply(DocNode targetDocument) throws DocUpdateException { + Object document = createMutableCopy(targetDocument.toBasicObject()); + int i = 0; + + for (Operation operation : this.operations) { + try { + if (operation instanceof Operation.Replace && ((Operation.Replace) operation).pointer.isRoot()) { + document = createMutableCopy(((Operation.Replace) operation).value); + } else if (!operation.apply(document)) { + return targetDocument; + } + } catch (PointerEvaluationException e) { + throw new DocUpdateException("Error while applying operation " + i + ": " + e.getMessage(), e); + } + + i++; + } + + return DocNode.wrap(document); + } + + @Override + public String getMediaType() { + return MEDIA_TYPE; + } + + private static Object createMutableCopy(Object object) { + if (object instanceof Map) { + LinkedHashMap<String, Object> result = new LinkedHashMap<>(((Map<?, ?>) object).size()); + + for (Map.Entry<?, ?> entry : ((Map<?, ?>) object).entrySet()) { + result.put(String.valueOf(entry.getKey()), createMutableCopy(entry.getValue())); + } + + return result; + } else if (object instanceof Collection) { + ArrayList<Object> result = new ArrayList<>(((Collection<?>) object).size()); + + for (Object element : ((Collection<?>) object)) { + result.add(createMutableCopy(element)); + } + + return result; + } else { + return object; + } + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((operations == null) ? 0 : operations.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof JsonPatch)) { + return false; + } + JsonPatch other = (JsonPatch) obj; + if (operations == null) { + if (other.operations != null) { + return false; + } + } else if (!operations.equals(other.operations)) { + return false; + } + return true; + } + + public boolean isEmpty() { + return operations.isEmpty(); + } + + public static JsonPatch fromDiff(DocNode source, DocNode target) { + if (source == null) { + source = DocNode.NULL; + } + if (target == null) { + target = DocNode.NULL; + } + + if (source.isNull() && target.isNull()) { + return new JsonPatch(); + } + + if (source.isNull()) { + return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, target.toBasicObject())); + } + + if (target.isNull()) { + return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, null)); + } + + if (source.isMap() && target.isMap()) { + ImmutableList.Builder<Operation> result = new ImmutableList.Builder<Operation>(); + diff(JsonPointer.ROOT, source.toMap(), target.toMap(), result); + return new JsonPatch(result.build()); + } else if (source.isList() && target.isList()) { + // TODO improve algorithm + return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, target.toBasicObject())); + } else { + return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, target.toBasicObject())); + } + } + + private static void diff(JsonPointer pointer, Map<?, ?> source, Map<?, ?> target, ImmutableList.Builder<Operation> result) { + for (Map.Entry<?, ?> sourceEntry : source.entrySet()) { + Object key = sourceEntry.getKey(); + Object sourceValue = sourceEntry.getValue(); + Object targetValue = target.get(key); + + if (sourceValue instanceof DocNode) { + sourceValue = ((DocNode) sourceValue).toBasicObject(); + } + + if (targetValue instanceof DocNode) { + targetValue = ((DocNode) targetValue).toBasicObject(); + } + + if (Objects.equals(sourceValue, targetValue)) { + continue; + } + + if (targetValue == null) { + result.add(new Operation.Remove(pointer.with(key.toString()))); + } else if (isScalar(targetValue)) { + result.add(new Operation.Replace(pointer.with(key.toString()), targetValue)); + } else if (isScalar(sourceValue)) { + result.add(new Operation.Replace(pointer.with(key.toString()), targetValue)); + } else if (sourceValue instanceof Map && targetValue instanceof Map) { + if (isAnyElementEqual((Map<?, ?>) sourceValue, (Map<?, ?>) targetValue)) { + diff(pointer.with(key.toString()), (Map<?, ?>) sourceValue, (Map<?, ?>) targetValue, result); + } else { + result.add(new Operation.Replace(pointer.with(key.toString()), targetValue)); + } + } else if (sourceValue instanceof Collection && targetValue instanceof Collection) { + // TODO improve algorithm + result.add(new Operation.Replace(pointer.with(key.toString()), targetValue)); + } else { + result.add(new Operation.Replace(pointer.with(key.toString()), targetValue)); + } + } + + for (Object key : target.keySet()) { + if (source.containsKey(key)) { + continue; + } + + Object targetValue = target.get(key); + result.add(new Operation.Add(pointer.with(key.toString()), targetValue)); + } + } + + private static boolean isScalar(Object o) { + return !(o instanceof Map || o instanceof Collection); + } + + private static boolean isAnyElementEqual(Map<?, ?> source, Map<?, ?> target) { + for (Map.Entry<?, ?> sourceEntry : source.entrySet()) { + Object key = sourceEntry.getKey(); + Object sourceValue = sourceEntry.getValue(); + Object targetValue = target.get(key); + + if (sourceValue instanceof DocNode) { + sourceValue = ((DocNode) sourceValue).toBasicObject(); + } + + if (targetValue instanceof DocNode) { + targetValue = ((DocNode) targetValue).toBasicObject(); + } + + if (sourceValue instanceof Map && targetValue instanceof Map) { + if (isAnyElementEqual((Map<?, ?>) sourceValue, (Map<?, ?>) targetValue)) { + return true; + } + } else if (Objects.equals(sourceValue, targetValue)) { + return true; + } + } + + return false; + } + + public static interface Operation { + + boolean apply(Object document) throws PointerEvaluationException; + + static Operation parse(DocNode docNode) throws ConfigValidationException { + ValidationErrors validationErrors = new ValidationErrors(); + ValidatingDocNode vNode = new ValidatingDocNode(docNode, validationErrors); + + String operation = vNode.get("op").required().asString(); + validationErrors.throwExceptionForPresentErrors(); + + switch (operation.toLowerCase()) { + case "add": + return new Add(vNode); + case "remove": + return new Remove(vNode); + case "replace": + return new Replace(vNode); + case "move": + return new Move(vNode); + case "copy": + return new Copy(vNode); + case "test": + return new Test(vNode); + default: + throw new ConfigValidationException(new InvalidAttributeValue("op", operation, "add|remove|replace|move|test")); + } + } + + public static class Add implements Operation, Document<Add> { + private final JsonPointer pointer; + private final Object value; + + Add(ValidatingDocNode vNode) throws ConfigValidationException { + this.pointer = vNode.get("path").required().byString(JsonPointer::parse); + this.value = vNode.get("value").required().asAnything(); + vNode.throwExceptionForPresentErrors(); + } + + Add(JsonPointer pointer, Object value) { + this.pointer = pointer; + this.value = value; + } + + @Override + public Object toBasicObject() { + return OrderedImmutableMap.of("op", "add", "path", pointer.toString(), "value", value); + } + + @Override + public boolean apply(Object document) throws PointerEvaluationException { + this.pointer.addAtPointedValue(document, value); + return true; + } + } + + public static class Remove implements Operation, Document<Remove> { + private final JsonPointer pointer; + + Remove(ValidatingDocNode vNode) throws ConfigValidationException { + this.pointer = vNode.get("path").required().byString(JsonPointer::parse); + vNode.throwExceptionForPresentErrors(); + } + + Remove(JsonPointer pointer) { + this.pointer = pointer; + } + + @Override + public Object toBasicObject() { + return OrderedImmutableMap.of("op", "remove", "path", pointer.toString()); + } + + @Override + public boolean apply(Object document) throws PointerEvaluationException { + this.pointer.removePointedValue(document); + return true; + } + } + + public static class Replace implements Operation, Document<Replace> { + private final JsonPointer pointer; + private final Object value; + + Replace(ValidatingDocNode vNode) throws ConfigValidationException { + this.pointer = vNode.get("path").required().byString(JsonPointer::parse); + this.value = vNode.get("value").required().asAnything(); + vNode.throwExceptionForPresentErrors(); + } + + Replace(JsonPointer pointer, Object value) { + this.pointer = pointer; + this.value = value; + } + + @Override + public Object toBasicObject() { + return OrderedImmutableMap.of("op", "replace", "path", pointer.toString(), "value", value); + } + + @Override + public boolean apply(Object document) throws PointerEvaluationException { + this.pointer.setPointedValue(document, value); + return true; + } + } + + public static class Move implements Operation, Document<Move> { + private final JsonPointer from; + private final JsonPointer to; + + Move(ValidatingDocNode vNode) throws ConfigValidationException { + this.from = vNode.get("from").required().byString(JsonPointer::parse); + this.to = vNode.get("path").required().byString(JsonPointer::parse); + vNode.throwExceptionForPresentErrors(); + } + + @Override + public Object toBasicObject() { + return OrderedImmutableMap.of("op", "move", "from", from.toString(), "path", to.toString()); + } + + @Override + public boolean apply(Object document) throws PointerEvaluationException { + Object value = this.from.removePointedValue(document); + this.to.addAtPointedValue(document, value); + return true; + } + } + + public static class Copy implements Operation, Document<Copy> { + private final JsonPointer from; + private final JsonPointer to; + + Copy(ValidatingDocNode vNode) throws ConfigValidationException { + this.from = vNode.get("from").required().byString(JsonPointer::parse); + this.to = vNode.get("path").required().byString(JsonPointer::parse); + vNode.throwExceptionForPresentErrors(); + } + + @Override + public Object toBasicObject() { + return OrderedImmutableMap.of("op", "copy", "from", from.toString(), "path", to.toString()); + } + + @Override + public boolean apply(Object document) throws PointerEvaluationException { + Object value = this.from.getPointedValue(document); + this.to.addAtPointedValue(document, createMutableCopy(value)); + return true; + } + } + + public static class Test implements Operation, Document<Test> { + private final JsonPointer pointer; + private final Object value; + + Test(ValidatingDocNode vNode) throws ConfigValidationException { + this.pointer = vNode.get("path").required().byString(JsonPointer::parse); + this.value = vNode.get("value").required().asAnything(); + vNode.throwExceptionForPresentErrors(); + } + + @Override + public Object toBasicObject() { + return OrderedImmutableMap.of("op", "test", "path", pointer.toString(), "value", value); + } + + @Override + public boolean apply(Object document) throws PointerEvaluationException { + Object currentValue = this.pointer.getPointedValue(document); + + if (Objects.equals(this.value, currentValue)) { + return true; + } else { + return false; + } + } + } + } +} diff --git a/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java b/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java index d33f3f11d046f3f49cdef089b988ef138dae6299..53dc26224afc86d5b78ff63ca8626c5765a92006 100644 --- a/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java +++ b/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java @@ -18,16 +18,17 @@ package com.floragunn.codova.documents.patch; import com.floragunn.codova.documents.DocNode; +import com.floragunn.codova.documents.DocUpdateException; import com.floragunn.codova.documents.Document; import com.floragunn.codova.documents.Parser; import com.floragunn.codova.validation.ConfigValidationException; public interface PatchableDocument<T> extends Document<T> { - default T patch(DocPatch patch, Parser.Context context) throws ConfigValidationException { + default T patch(DocPatch patch, Parser.Context context) throws ConfigValidationException, DocUpdateException { DocNode patchedDocNode = patch.apply(this.toDocNode()); - + return parseI(patchedDocNode, context); } - + T parseI(DocNode docNode, Parser.Context context) throws ConfigValidationException; } diff --git a/src/main/java/com/floragunn/codova/documents/pointer/DocPointer.java b/src/main/java/com/floragunn/codova/documents/pointer/DocPointer.java new file mode 100644 index 0000000000000000000000000000000000000000..7b910308336df003c16969fbcec5643c78645d3a --- /dev/null +++ b/src/main/java/com/floragunn/codova/documents/pointer/DocPointer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.floragunn.codova.documents.pointer; + +import com.floragunn.codova.documents.DocNode; + +public interface DocPointer { + default DocNode getPointedValueAsNode(DocNode docNode) throws PointerEvaluationException { + return DocNode.wrap(getPointedValue(docNode.toBasicObject())); + } + + default Object getPointedValue(DocNode docNode) throws PointerEvaluationException { + return getPointedValue(docNode.toBasicObject()); + } + + Object getPointedValue(Object objectTreeNode) throws PointerEvaluationException; + + void setPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException; + + void addAtPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException; + + Object removePointedValue(Object objectTreeNode) throws PointerEvaluationException; + + DocPointer getParent(); +} diff --git a/src/main/java/com/floragunn/codova/documents/pointer/JsonPointer.java b/src/main/java/com/floragunn/codova/documents/pointer/JsonPointer.java new file mode 100644 index 0000000000000000000000000000000000000000..052e618d741dc1f0c1f79b7e6528a9db7569efb0 --- /dev/null +++ b/src/main/java/com/floragunn/codova/documents/pointer/JsonPointer.java @@ -0,0 +1,348 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.floragunn.codova.documents.pointer; + +import java.util.List; +import java.util.Map; + +import com.floragunn.codova.validation.ConfigValidationException; +import com.floragunn.codova.validation.errors.ValidationError; +import com.floragunn.fluent.collections.ImmutableList; + +public class JsonPointer implements DocPointer { + public static final JsonPointer ROOT = new JsonPointer(ImmutableList.empty(), ""); + + private final ImmutableList<ReferenceToken> tokens; + private final String source; + private JsonPointer parent; + + public static JsonPointer parse(String jsonPointer) throws ConfigValidationException { + if (jsonPointer.length() == 0) { + return ROOT; + } + + if (jsonPointer.charAt(0) != '/') { + throw new ConfigValidationException(new ValidationError(null, "Invalid character at first position")); + } + + String[] tokenStrings = jsonPointer.substring(1).split("/"); + ImmutableList.Builder<ReferenceToken> tokens = new ImmutableList.Builder<>(tokenStrings.length); + + for (int i = 0; i < tokenStrings.length; i++) { + tokens.add(new ReferenceToken(tokenStrings[i])); + } + + return new JsonPointer(tokens.build(), jsonPointer); + } + + JsonPointer(ImmutableList<ReferenceToken> tokens, String source) { + this.tokens = tokens; + this.source = source; + + if (tokens.size() == 1) { + this.parent = ROOT; + } + } + + @Override + public Object getPointedValue(Object objectTreeNode) throws PointerEvaluationException { + int tokenSize = this.tokens.size(); + + if (tokenSize == 0) { + return objectTreeNode; + } + + Object currentNode = objectTreeNode; + + for (int i = 0; i < tokenSize; i++) { + ReferenceToken token = this.tokens.get(i); + + if (currentNode instanceof List) { + if (token.index == ReferenceToken.NON_NUMERIC) { + throw new PointerEvaluationException(token.content + " is not a valid array index"); + } else if (token.index == ReferenceToken.LAST) { + throw new PointerEvaluationException(token.content + " cannot be used for reading a value from an index"); + } else { + List<?> list = (List<?>) currentNode; + if (list.size() > token.index) { + currentNode = list.get(token.index); + } else { + throw new PointerEvaluationException(token.index + " out of bounds for array of size " + list.size()); + } + } + } else if (currentNode instanceof Map) { + currentNode = ((Map<?, ?>) currentNode).get(token.content); + } else { + throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object."); + } + } + + return currentNode; + } + + @Override + public void setPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException { + Object containerNode = getParent().getPointedValue(objectTreeNode); + ReferenceToken token = this.tokens.get(this.tokens.size() - 1); + + if (containerNode instanceof List) { + @SuppressWarnings("unchecked") + List<Object> list = (List<Object>) containerNode; + + if (token.index == ReferenceToken.NON_NUMERIC) { + throw new PointerEvaluationException(token.content + " is not a valid array index"); + } else if (token.index == ReferenceToken.LAST) { + list.add(newValue); + } else if (token.index < list.size()) { + list.set(token.index, newValue); + } + } else if (containerNode instanceof Map) { + @SuppressWarnings("unchecked") + Map<String, Object> map = (Map<String, Object>) containerNode; + map.put(token.content, newValue); + } else { + throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object."); + } + } + + @Override + public void addAtPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException { + Object containerNode = getParent().getPointedValue(objectTreeNode); + ReferenceToken token = this.tokens.get(this.tokens.size() - 1); + + if (containerNode instanceof List) { + @SuppressWarnings("unchecked") + List<Object> list = (List<Object>) containerNode; + + if (token.index == ReferenceToken.NON_NUMERIC) { + throw new PointerEvaluationException(token.content + " is not a valid array index"); + } else if (token.index == ReferenceToken.LAST) { + list.add(newValue); + } else if (token.index < list.size()) { + list.add(token.index, newValue); + } + } else if (containerNode instanceof Map) { + @SuppressWarnings("unchecked") + Map<String, Object> map = (Map<String, Object>) containerNode; + map.put(token.content, newValue); + } else { + throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object."); + } + } + + @Override + public Object removePointedValue(Object objectTreeNode) throws PointerEvaluationException { + Object containerNode = getParent().getPointedValue(objectTreeNode); + + ReferenceToken token = this.tokens.get(this.tokens.size() - 1); + + if (containerNode instanceof List) { + @SuppressWarnings("unchecked") + List<Object> list = (List<Object>) containerNode; + + if (token.index == ReferenceToken.NON_NUMERIC) { + return null; + } else if (token.index == ReferenceToken.LAST) { + return null; + } else if (token.index < list.size()) { + return list.remove(token.index); + } else { + return null; + } + } else if (containerNode instanceof Map) { + @SuppressWarnings("unchecked") + Map<String, Object> map = (Map<String, Object>) containerNode; + return map.remove(token.content); + } else { + throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object."); + } + } + + public JsonPointer with(String referenceToken) { + String escaped = ReferenceToken.escape(referenceToken); + return new JsonPointer(tokens.with(new ReferenceToken(escaped)), this.source + "/" + escaped); + } + + public JsonPointer with(int referenceToken) { + return new JsonPointer(tokens.with(new ReferenceToken(referenceToken)), this.source + "/" + referenceToken); + } + + public boolean isRoot() { + return tokens.isEmpty(); + } + + static class ReferenceToken { + static final int LAST = -1; + static final int NON_NUMERIC = -2; + + private final String source; + private final String content; + private final int index; + + ReferenceToken(String tokenString) { + this.source = tokenString; + this.content = unescapeTokenString(tokenString); + this.index = parseToIndex(tokenString); + } + + ReferenceToken(int index) { + this.source = this.content = index == LAST ? "-" : String.valueOf(index); + this.index = index; + } + + static String escape(String tokenString) { + if (!needsEscaping(tokenString)) { + return tokenString; + } + + StringBuilder result = new StringBuilder(tokenString.length() + 10); + + for (int i = 0; i < tokenString.length(); i++) { + char c = tokenString.charAt(i); + switch (c) { + case '~': + result.append("~0"); + break; + case '/': + result.append("~1"); + break; + default: + result.append(c); + } + } + + return result.toString(); + } + + static boolean needsEscaping(String tokenString) { + int length = tokenString.length(); + + for (int i = 0; i < length; i++) { + switch (tokenString.charAt(i)) { + case '~': + case '/': + return true; + } + } + + return false; + } + + private static String unescapeTokenString(String tokenString) { + if (tokenString.indexOf('~') == -1) { + return tokenString; + } else { + return tokenString.replace("~1", "/").replace("~0", "~"); + } + } + + @Override + public String toString() { + return source; + } + + private static int parseToIndex(String tokenString) { + if (tokenString.equals("-")) { + return LAST; + } else if (isNumeric(tokenString)) { + return Integer.parseInt(tokenString); + } else { + return NON_NUMERIC; + } + } + + private static boolean isNumeric(String tokenString) { + int length = tokenString.length(); + + if (length == 0) { + return false; + } + + for (int i = 0; i < length; i++) { + char c = tokenString.charAt(i); + if (!(c >= '0' && c <= '9')) { + return false; + } + } + + return true; + } + + @Override + public int hashCode() { + return content.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ReferenceToken)) { + return false; + } + ReferenceToken other = (ReferenceToken) obj; + + return this.content.equals(other.content); + } + + } + + @Override + public String toString() { + return source; + } + + @Override + public DocPointer getParent() { + JsonPointer parent = this.parent; + + if (parent == null) { + if (tokens.size() == 0) { + return null; + } else if (tokens.size() == 1) { + return ROOT; + } else { + this.parent = parent = new JsonPointer((ImmutableList<ReferenceToken>) tokens.subList(0, tokens.size() - 1), + source.substring(0, this.source.lastIndexOf('/'))); + return parent; + } + } else { + return parent; + } + } + + @Override + public int hashCode() { + return tokens.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof JsonPointer)) { + return false; + } + JsonPointer other = (JsonPointer) obj; + + return this.tokens.equals(other.tokens); + } + +} diff --git a/src/main/java/com/floragunn/codova/documents/pointer/PointerEvaluationException.java b/src/main/java/com/floragunn/codova/documents/pointer/PointerEvaluationException.java new file mode 100644 index 0000000000000000000000000000000000000000..b8b133d92364e679603c19c7446c5fdf5156c7f3 --- /dev/null +++ b/src/main/java/com/floragunn/codova/documents/pointer/PointerEvaluationException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.floragunn.codova.documents.pointer; + +public class PointerEvaluationException extends Exception { + + private static final long serialVersionUID = 770109146930861850L; + + public PointerEvaluationException(String message, Throwable cause) { + super(message, cause); + } + + public PointerEvaluationException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java b/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java index 286475524f70d4a824641f827925cb440fab9053..90cf1ce7ac1668fc4b15797dd46ca41e089b31c8 100644 --- a/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java +++ b/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java @@ -276,6 +276,10 @@ public class ValidatingDocNode { return documentNode; } + public void throwExceptionForPresentErrors() throws ConfigValidationException { + validationErrors.throwExceptionForPresentErrors(); + } + public abstract class AbstractAttribute<T> { protected final String name; protected final String fullAttributePath; diff --git a/src/test/java/com/floragunn/codova/documents/JsonPointerTest.java b/src/test/java/com/floragunn/codova/documents/JsonPointerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..f9cb59dd4a0a91f4aaf8ba39ef4588b3b21e7f8e --- /dev/null +++ b/src/test/java/com/floragunn/codova/documents/JsonPointerTest.java @@ -0,0 +1,148 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.floragunn.codova.documents; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; + +import com.floragunn.codova.documents.pointer.JsonPointer; +import com.floragunn.fluent.collections.ImmutableList; +import com.floragunn.fluent.collections.OrderedImmutableMap; + +public class JsonPointerTest { + @Test + public void getPointedValue_root() throws Exception { + Object o = DocNode.of("a.a1", 1, "a.a2", 2).toDeepBasicObject(); + Assert.assertEquals(o, JsonPointer.parse("").getPointedValue(o)); + } + + @Test + public void getPointedValue_topLevel() throws Exception { + Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 2), "b", 100); + Assert.assertEquals(o.get("a"), JsonPointer.parse("/a").getPointedValue(o)); + Assert.assertEquals(o.get("b"), JsonPointer.parse("/b").getPointedValue(o)); + } + + @Test + public void getPointedValue_escape_code0() throws Exception { + Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("~", 1, "a2", 2), "b", 100); + Assert.assertEquals(o.get("~"), JsonPointer.parse("/~0").getPointedValue(o)); + } + + @Test + public void getPointedValue_escape_code1() throws Exception { + Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("/", 1, "a2", 2), "b", 100); + Assert.assertEquals(o.get("/"), JsonPointer.parse("/~1").getPointedValue(o)); + } + + @Test + public void getPointedValue_nested() throws Exception { + Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 2)); + Assert.assertEquals(((Map<?, ?>) o.get("a")).get("a1"), JsonPointer.parse("/a/a1").getPointedValue(o)); + } + + @Test + public void getPointedValue_numericMapKey() throws Exception { + Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("1", 1, "2", 2)); + Assert.assertEquals(((Map<?, ?>) o.get("a")).get("1"), JsonPointer.parse("/a/1").getPointedValue(o)); + } + + @Test + public void getPointedValue_array() throws Exception { + Map<String, Object> o = OrderedImmutableMap.of("a", ImmutableList.of(1, 5, 9, 42)); + Assert.assertEquals(((List<?>) o.get("a")).get(3), JsonPointer.parse("/a/3").getPointedValue(o)); + } + + @Test + public void getPointedValue_array_root() throws Exception { + List<Object> o = ImmutableList.of(1, 5, 9, 42); + Assert.assertEquals(o.get(3), JsonPointer.parse("/3").getPointedValue(o)); + } + + @Test + public void setPointedValue_topLevel() throws Exception { + OrderedImmutableMap<String, Object> base = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 2)); + Map<String, Object> subject = new HashMap<>(base); + OrderedImmutableMap<String, Object> reference = base.with("a", 5); + JsonPointer.parse("/a").setPointedValue(subject, 5); + + Assert.assertEquals(reference, subject); + } + + @Test + public void setPointedValue_nested() throws Exception { + Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new LinkedHashMap<>(OrderedImmutableMap.of("a1", 1, "a2", 2)))); + Map<String, Object> reference = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 5)); + JsonPointer.parse("/a/a2").setPointedValue(subject, 5); + + Assert.assertEquals(reference, subject); + } + + @Test + public void addAtPointedValue_nested_array() throws Exception { + Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30)))); + Map<String, Object> reference = OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 25, 30))); + JsonPointer.parse("/a/2").addAtPointedValue(subject, 25); + + Assert.assertEquals(reference, subject); + } + + @Test + public void addAtPointedValue_array_end() throws Exception { + Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30)))); + Map<String, Object> reference = OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30, 40))); + JsonPointer.parse("/a/-").addAtPointedValue(subject, 40); + + Assert.assertEquals(reference, subject); + } + + @Test + public void removePointedValue_nested_object() throws Exception { + Map<String, Object> subject = new LinkedHashMap<>( + OrderedImmutableMap.of("a", new LinkedHashMap<>(OrderedImmutableMap.of("a1", 10, "a2", 20)))); + Map<String, Object> reference = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 10)); + JsonPointer.parse("/a/a2").removePointedValue(subject); + + Assert.assertEquals(reference, subject); + } + + @Test + public void removePointedValue_nested_array() throws Exception { + Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30)))); + Map<String, Object> reference = OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 30))); + JsonPointer.parse("/a/1").removePointedValue(subject); + + Assert.assertEquals(reference, subject); + } + + @Test + public void getParent() throws Exception { + JsonPointer pointer = JsonPointer.parse("/a/b/c"); + Assert.assertEquals(JsonPointer.parse("/a/b"), pointer.getParent()); + Assert.assertEquals(JsonPointer.parse("/a"), pointer.getParent().getParent()); + Assert.assertEquals(JsonPointer.parse(""), pointer.getParent().getParent().getParent()); + } + +} diff --git a/src/test/java/com/floragunn/codova/documents/patch/JsonPatchTest.java b/src/test/java/com/floragunn/codova/documents/patch/JsonPatchTest.java new file mode 100644 index 0000000000000000000000000000000000000000..802514cf9043b45e54eff333f14eda2bbcf7e536 --- /dev/null +++ b/src/test/java/com/floragunn/codova/documents/patch/JsonPatchTest.java @@ -0,0 +1,224 @@ +/* + * Copyright 2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.floragunn.codova.documents.patch; + +import java.util.Arrays; + +import org.junit.Assert; +import org.junit.Test; + +import com.floragunn.codova.documents.DocNode; + +public class JsonPatchTest { + @Test + public void add() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "add", "path", "/a/a3", "value", 30))).apply(base); + DocNode reference = DocNode.of("a.a1", 10, "a.a2", 20, "a.a3", 30); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void add_array() throws Exception { + DocNode base = DocNode.of("a", Arrays.asList(10, 20, 30)); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "add", "path", "/a/2", "value", 25))).apply(base); + DocNode reference = DocNode.of("a", Arrays.asList(10, 20, 25, 30)); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void remove() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "remove", "path", "/a/a2"))).apply(base); + DocNode reference = DocNode.of("a.a1", 10); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void remove_array() throws Exception { + DocNode base = DocNode.of("a", Arrays.asList(10, 20, 30)); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "remove", "path", "/a/1"))).apply(base); + DocNode reference = DocNode.of("a", Arrays.asList(10, 30)); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void replace() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "replace", "path", "/a/a2", "value", 25))).apply(base); + DocNode reference = DocNode.of("a.a1", 10, "a.a2", 25); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void replace_array() throws Exception { + DocNode base = DocNode.of("a", Arrays.asList(10, 20, 30)); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "replace", "path", "/a/1", "value", 25))).apply(base); + DocNode reference = DocNode.of("a", Arrays.asList(10, 25, 30)); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void replace_root() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "replace", "path", "", "value", 25))).apply(base); + DocNode reference = DocNode.wrap(25); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void copy() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "copy", "path", "/a/b", "from", "/a"))).apply(base); + DocNode reference = DocNode.of("a.a1", 10, "a.a2", 20, "a.b.a1", 10, "a.b.a2", 20); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void move() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "move", "path", "/a/a3", "from", "/a/a2"))).apply(base); + DocNode reference = DocNode.of("a.a1", 10, "a.a3", 20); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void test_success() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch( + DocNode.array(DocNode.of("op", "test", "path", "/a/a2", "value", 20), DocNode.of("op", "add", "path", "/a/a3", "value", 30))) + .apply(base); + DocNode reference = DocNode.of("a.a1", 10, "a.a2", 20, "a.a3", 30); + Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void test_fail() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch( + DocNode.array(DocNode.of("op", "test", "path", "/a/a2", "value", 21), DocNode.of("op", "add", "path", "/a/a3", "value", 30))) + .apply(base); + Assert.assertEquals(base.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void test_fail_atomic() throws Exception { + DocNode base = DocNode.of("a.a1", 10, "a.a2", 20); + DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "add", "path", "/a/a5", "value", 50), + DocNode.of("op", "test", "path", "/a/a2", "value", 21), DocNode.of("op", "add", "path", "/a/a3", "value", 30))).apply(base); + Assert.assertEquals(base.toDeepBasicObject(), subject.toDeepBasicObject()); + } + + @Test + public void diff_equal_simple() throws Exception { + DocNode d1 = DocNode.of("a", 1); + DocNode d2 = DocNode.of("a", 1); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertTrue(diff.toJsonString(), diff.isEmpty()); + } + + @Test + public void diff_equal_moreComplex() throws Exception { + DocNode d1 = DocNode.of("a", 1, "b", Arrays.asList(1, 2, 3), "c.c1", "C1", "c.c2", "C2"); + DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "c.c2", "C2", "b", Arrays.asList(1, 2, 3)); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertTrue(diff.toJsonString(), diff.isEmpty()); + } + + @Test + public void diff_replace_simple() throws Exception { + DocNode d1 = DocNode.of("a", 1); + DocNode d2 = DocNode.of("a", 2); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertFalse(diff.toJsonString(), diff.isEmpty()); + Assert.assertEquals(d2.toBasicObject(), diff.apply(d1).toBasicObject()); + } + + @Test + public void diff_replace_moreComplex() throws Exception { + DocNode d1 = DocNode.of("a", 1, "b", Arrays.asList(1, 2, 3), "c.c1", "C1", "c.c2", "C2"); + DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "c.c2", "CX", "b", Arrays.asList(1, 2, 99, 3)); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertFalse(diff.toJsonString(), diff.isEmpty()); + Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject()); + } + + @Test + public void diff_replace_depth_3() throws Exception { + DocNode d1 = DocNode.of("a", 1, "b", 2, "c.c.c1", "C1", "c.c.c2", "C2"); + DocNode d2 = DocNode.of("a", 1, "c.c.c1", "C1", "c.c.c2", "CX", "b", 2); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + System.out.println(diff.toJsonString()); + Assert.assertFalse(diff.toJsonString(), diff.isEmpty()); + Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject()); + } + + @Test + public void diff_delete_simple() throws Exception { + DocNode d1 = DocNode.of("a", 1); + DocNode d2 = DocNode.EMPTY; + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertFalse(diff.toJsonString(), diff.isEmpty()); + Assert.assertEquals(d2.toBasicObject(), diff.apply(d1).toBasicObject()); + } + + @Test + public void diff_delete_moreComplex() throws Exception { + DocNode d1 = DocNode.of("a", 1, "b", Arrays.asList(1, 2, 3), "c.c1", "C1", "c.c2", "C2"); + DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "b", Arrays.asList(1, 3)); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertFalse(diff.toJsonString(), diff.isEmpty()); + Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject()); + } + + @Test + public void diff_add_simple() throws Exception { + DocNode d1 = DocNode.of("a", 1); + DocNode d2 = DocNode.of("a", 1, "b", 2); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertFalse(diff.toJsonString(), diff.isEmpty()); + Assert.assertEquals(d2.toBasicObject(), diff.apply(d1).toBasicObject()); + } + + @Test + public void diff_add_moreComplex() throws Exception { + DocNode d1 = DocNode.of("a", 1); + DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "c.c2", "C2"); + + JsonPatch diff = JsonPatch.fromDiff(d1, d2); + + Assert.assertFalse(diff.toJsonString(), diff.isEmpty()); + Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject()); + } +}