From 3f7183f356317e8c6cd7633602b39b90982f2040 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Wed, 20 May 2026 01:03:31 +0200 Subject: [PATCH 1/2] feat: add path-preserving resolution and enhanced node path capabilities Implemented path-preserving resolution methods in `Blue` to maintain specified or matching node paths during resolution, including `resolvePreservingPaths` and `resolvePreservingMatchingPaths`. Added utilities like `NodePathSelector` and `NodePathEditor` for advanced path manipulation, and introduced `ExcludedPathLimits` to restrict merge operations at specific paths. Updated `ProcessorExecutionContext` and `Node` to support new node path-related methods. Added comprehensive test coverage with `MaskedResolutionTest`. --- src/main/java/blue/language/Blue.java | 59 ++++ src/main/java/blue/language/merge/Merger.java | 80 +++-- src/main/java/blue/language/model/Node.java | 8 + .../processor/ProcessorExecutionContext.java | 18 ++ .../blue/language/snapshot/FrozenNode.java | 5 + .../blue/language/utils/NodePathAccessor.java | 51 ++++ .../blue/language/utils/NodePathEditor.java | 114 ++++++++ .../blue/language/utils/NodePathSelector.java | 136 +++++++++ .../utils/limits/ExcludedPathLimits.java | 91 ++++++ .../blue/language/MaskedResolutionTest.java | 274 ++++++++++++++++++ 10 files changed, 806 insertions(+), 30 deletions(-) create mode 100644 src/main/java/blue/language/utils/NodePathEditor.java create mode 100644 src/main/java/blue/language/utils/NodePathSelector.java create mode 100644 src/main/java/blue/language/utils/limits/ExcludedPathLimits.java create mode 100644 src/test/java/blue/language/MaskedResolutionTest.java diff --git a/src/main/java/blue/language/Blue.java b/src/main/java/blue/language/Blue.java index 7b72c7c..122e386 100644 --- a/src/main/java/blue/language/Blue.java +++ b/src/main/java/blue/language/Blue.java @@ -26,6 +26,7 @@ import blue.language.snapshot.ResolvedSnapshot; import blue.language.utils.*; import blue.language.utils.limits.CompositeLimits; +import blue.language.utils.limits.ExcludedPathLimits; import blue.language.utils.limits.Limits; import java.util.ArrayList; @@ -40,6 +41,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.Predicate; import static blue.language.utils.UncheckedObjectMapper.JSON_MAPPER; import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; @@ -109,6 +111,52 @@ public Node resolve(Node node, Limits limits) { return merger.resolve(node, effectiveLimits); } + public Node resolvePreservingPaths(Node node, Collection preservedPaths) { + return resolvePreservingPaths(node, NO_LIMITS, preservedPaths); + } + + public Node resolvePreservingPaths(Node node, Limits limits, Collection preservedPaths) { + if (node == null) { + throw new IllegalArgumentException("node must not be null"); + } + Set canonicalPreservedPaths = canonicalPreservedPaths(preservedPaths); + if (canonicalPreservedPaths.isEmpty()) { + return resolve(node.clone(), limits); + } + if (canonicalPreservedPaths.contains("/")) { + return node.clone(); + } + + Limits preservingLimits = limits == NO_LIMITS + ? ExcludedPathLimits.excluding(canonicalPreservedPaths) + : new CompositeLimits(limits, ExcludedPathLimits.excluding(canonicalPreservedPaths)); + Node resolved = resolve(node.clone(), preservingLimits); + for (String path : canonicalPreservedPaths) { + Node preserved = NodePathEditor.getOrNull(node, path); + if (preserved != null) { + NodePathEditor.put(resolved, path, preserved.clone()); + } + } + return resolved; + } + + public List selectPaths(Node node, Collection pathPatterns, Predicate predicate) { + return NodePathSelector.select(node, pathPatterns, predicate); + } + + public Node resolvePreservingMatchingPaths(Node node, + Collection pathPatterns, + Predicate predicate) { + return resolvePreservingMatchingPaths(node, NO_LIMITS, pathPatterns, predicate); + } + + public Node resolvePreservingMatchingPaths(Node node, + Limits limits, + Collection pathPatterns, + Predicate predicate) { + return resolvePreservingPaths(node, limits, selectPaths(node, pathPatterns, predicate)); + } + public Node reverse(Node node) { return new MergeReverser().reverse(node); } @@ -612,6 +660,17 @@ private boolean canMinimizePatchedOverride(JsonPatch patch) { return true; } + private Set canonicalPreservedPaths(Collection preservedPaths) { + if (preservedPaths == null || preservedPaths.isEmpty()) { + return Collections.emptySet(); + } + Set canonicalPaths = new HashSet<>(); + for (String preservedPath : preservedPaths) { + canonicalPaths.add(JsonPointer.canonicalize(preservedPath)); + } + return canonicalPaths; + } + private NodeProvider processorSnapshotNodeProvider() { Set processorTypeBlueIds = new HashSet<>(PROCESSOR_MANAGED_TYPE_BLUE_IDS); if (documentProcessor != null) { diff --git a/src/main/java/blue/language/merge/Merger.java b/src/main/java/blue/language/merge/Merger.java index 371a80c..7040434 100644 --- a/src/main/java/blue/language/merge/Merger.java +++ b/src/main/java/blue/language/merge/Merger.java @@ -115,8 +115,11 @@ private void mergeObject(Node target, Node source, Limits limits) { properties.forEach((key, value) -> { if (limits.shouldMergePathSegment(key, value)) { limits.enterPathSegment(key, value); - mergeProperty(target, key, value, limits); - limits.exitPathSegment(); + try { + mergeProperty(target, key, value, limits); + } finally { + limits.exitPathSegment(); + } } }); } @@ -137,17 +140,17 @@ private void mergeChildren(Node target, List sourceChildren, Limits limits if (targetChildren == null) { if (startsWithPrevious(sourceChildren)) { - targetChildren = resolvePreviousAnchor(sourceChildren.get(0), limits); + targetChildren = resolvePreviousAnchor(sourceChildren.get(0), limits, target.getItemType()); target.items(targetChildren); validatePreviousAnchor(targetChildren, sourceChildren.get(0)); if (LIST_MERGE_POLICY_APPEND_ONLY.equals(mergePolicy)) { - mergeAppendOnlyChildren(targetChildren, sourceChildren, limits); + mergeAppendOnlyChildren(targetChildren, sourceChildren, limits, target.getItemType()); } else { - mergePositionalChildren(targetChildren, sourceChildren, limits); + mergePositionalChildren(targetChildren, sourceChildren, limits, target.getItemType()); } return; } - targetChildren = resolveInitialChildren(sourceChildren, limits); + targetChildren = resolveInitialChildren(sourceChildren, limits, target.getItemType()); target.items(targetChildren); return; } @@ -157,13 +160,13 @@ private void mergeChildren(Node target, List sourceChildren, Limits limits } if (LIST_MERGE_POLICY_APPEND_ONLY.equals(mergePolicy)) { - mergeAppendOnlyChildren(targetChildren, sourceChildren, limits); + mergeAppendOnlyChildren(targetChildren, sourceChildren, limits, target.getItemType()); } else { - mergePositionalChildren(targetChildren, sourceChildren, limits); + mergePositionalChildren(targetChildren, sourceChildren, limits, target.getItemType()); } } - private List resolveInitialChildren(List sourceChildren, Limits limits) { + private List resolveInitialChildren(List sourceChildren, Limits limits, Node itemType) { List result = new ArrayList<>(); int start = startsWithPrevious(sourceChildren) ? 1 : 0; for (int i = start; i < sourceChildren.size(); i++) { @@ -175,7 +178,7 @@ private List resolveInitialChildren(List sourceChildren, Limits limi } child = withoutPosition(child); } - Node resolvedChild = resolveListChild(child, limits, String.valueOf(result.size())); + Node resolvedChild = resolveListChild(child, limits, String.valueOf(result.size()), itemType); if (resolvedChild != null) { result.add(resolvedChild); } @@ -183,9 +186,9 @@ private List resolveInitialChildren(List sourceChildren, Limits limi return result; } - private void mergeAppendOnlyChildren(List targetChildren, List sourceChildren, Limits limits) { + private void mergeAppendOnlyChildren(List targetChildren, List sourceChildren, Limits limits, Node itemType) { if (startsWithPrevious(sourceChildren)) { - appendChildren(targetChildren, sourceChildren, 1, limits); + appendChildren(targetChildren, sourceChildren, 1, limits, itemType); return; } @@ -197,13 +200,13 @@ private void mergeAppendOnlyChildren(List targetChildren, List sourc for (int i = 0; i < sourceChildren.size(); i++) { if (i >= targetChildren.size()) { - Node resolvedChild = resolveListChild(sourceChildren.get(i), limits, String.valueOf(i)); + Node resolvedChild = resolveListChild(sourceChildren.get(i), limits, String.valueOf(i), itemType); if (resolvedChild != null) { targetChildren.add(resolvedChild); } continue; } - Node sourceChild = resolveListChild(sourceChildren.get(i), limits, String.valueOf(i)); + Node sourceChild = resolveListChild(sourceChildren.get(i), limits, String.valueOf(i), itemType); if (sourceChild == null) { continue; } @@ -217,16 +220,16 @@ private void mergeAppendOnlyChildren(List targetChildren, List sourc } } - private void mergePositionalChildren(List targetChildren, List sourceChildren, Limits limits) { + private void mergePositionalChildren(List targetChildren, List sourceChildren, Limits limits, Node itemType) { boolean hasPositionControls = sourceChildren.stream().anyMatch(child -> child.getPosition() != null); int start = startsWithPrevious(sourceChildren) ? 1 : 0; if (!hasPositionControls) { if (startsWithPrevious(sourceChildren)) { - appendChildren(targetChildren, sourceChildren, start, limits); + appendChildren(targetChildren, sourceChildren, start, limits, itemType); return; } - mergeLegacyPositionalChildren(targetChildren, sourceChildren, start, limits); + mergeLegacyPositionalChildren(targetChildren, sourceChildren, start, limits, itemType); return; } @@ -241,9 +244,9 @@ private void mergePositionalChildren(List targetChildren, List sourc if (!positions.add(position)) { throw new IllegalArgumentException("Duplicate \"$pos\" value in list: " + position); } - mergeOrReplacePosition(targetChildren, position, withoutPosition(sourceChild), limits); + mergeOrReplacePosition(targetChildren, position, withoutPosition(sourceChild), limits, itemType); } else { - Node resolvedChild = resolveListChild(sourceChild, limits, String.valueOf(targetChildren.size())); + Node resolvedChild = resolveListChild(sourceChild, limits, String.valueOf(targetChildren.size()), itemType); if (resolvedChild != null) { targetChildren.add(resolvedChild); } @@ -251,7 +254,7 @@ private void mergePositionalChildren(List targetChildren, List sourc } } - private void mergeLegacyPositionalChildren(List targetChildren, List sourceChildren, int start, Limits limits) { + private void mergeLegacyPositionalChildren(List targetChildren, List sourceChildren, int start, Limits limits, Node itemType) { int sourceLength = sourceChildren.size() - start; if (sourceLength < targetChildren.size()) { throw new IllegalArgumentException(String.format( @@ -263,7 +266,7 @@ private void mergeLegacyPositionalChildren(List targetChildren, List for (int i = 0; i < sourceLength; i++) { Node sourceChild = sourceChildren.get(start + i); if (i >= targetChildren.size()) { - Node resolvedChild = resolveListChild(sourceChild, limits, String.valueOf(i)); + Node resolvedChild = resolveListChild(sourceChild, limits, String.valueOf(i), itemType); if (resolvedChild != null) { targetChildren.add(resolvedChild); } @@ -273,16 +276,19 @@ private void mergeLegacyPositionalChildren(List targetChildren, List } } - private void mergeOrReplacePosition(List targetChildren, int position, Node overlay, Limits limits) { + private void mergeOrReplacePosition(List targetChildren, int position, Node overlay, Limits limits, Node itemType) { + Node effectiveItemType = targetChildren.get(position).getType() != null + ? targetChildren.get(position).getType() + : itemType; if (isEmptyPlaceholder(targetChildren.get(position)) || overlay.getValue() != null || overlay.getItems() != null) { - Node resolvedChild = resolveListChild(overlay, limits, String.valueOf(position)); + Node resolvedChild = resolveListChild(overlay, limits, String.valueOf(position), effectiveItemType); if (resolvedChild != null) { targetChildren.set(position, resolvedChild); } return; } if (overlay.getType() != null) { - Node resolvedOverlay = resolveListChild(overlay, limits, String.valueOf(position)); + Node resolvedOverlay = resolveListChild(overlay, limits, String.valueOf(position), effectiveItemType); if (resolvedOverlay != null) { mergeObject(targetChildren.get(position), resolvedOverlay, limits); } @@ -291,16 +297,16 @@ private void mergeOrReplacePosition(List targetChildren, int position, Nod merge(targetChildren.get(position), overlay, limits); } - private void appendChildren(List targetChildren, List sourceChildren, int start, Limits limits) { + private void appendChildren(List targetChildren, List sourceChildren, int start, Limits limits, Node itemType) { for (int i = start; i < sourceChildren.size(); i++) { - Node resolvedChild = resolveListChild(sourceChildren.get(i), limits, String.valueOf(targetChildren.size())); + Node resolvedChild = resolveListChild(sourceChildren.get(i), limits, String.valueOf(targetChildren.size()), itemType); if (resolvedChild != null) { targetChildren.add(resolvedChild); } } } - private List resolvePreviousAnchor(Node previousAnchor, Limits limits) { + private List resolvePreviousAnchor(Node previousAnchor, Limits limits, Node itemType) { List fetched = nodeProvider.fetchByBlueId(previousAnchor.getPreviousBlueId()); if (fetched == null || fetched.isEmpty()) { throw new IllegalArgumentException("No content found for $previous blueId: " + previousAnchor.getPreviousBlueId()); @@ -311,7 +317,7 @@ private List resolvePreviousAnchor(Node previousAnchor, Limits limits) { : fetched; List resolved = new ArrayList<>(); for (int i = 0; i < previousChildren.size(); i++) { - Node resolvedChild = resolveListChild(previousChildren.get(i), limits, String.valueOf(i)); + Node resolvedChild = resolveListChild(previousChildren.get(i), limits, String.valueOf(i), itemType); if (resolvedChild != null) { resolved.add(resolvedChild); } @@ -342,7 +348,7 @@ private boolean isEmptyPlaceholder(Node node) { && node.getValueType() == null; } - private Node resolveListChild(Node child, Limits limits, String segment) { + private Node resolveListChild(Node child, Limits limits, String segment, Node itemType) { if (child.getPreviousBlueId() != null || child.getPosition() != null) { throw new IllegalArgumentException("List control items must be consumed before resolving list children."); } @@ -351,12 +357,26 @@ private Node resolveListChild(Node child, Limits limits, String segment) { } limits.enterPathSegment(segment, child); try { - return resolve(child, limits); + return resolve(applyItemType(child, itemType), limits); } finally { limits.exitPathSegment(); } } + private Node applyItemType(Node child, Node itemType) { + if (child.getType() != null || child.getBlueId() != null || itemType == null) { + return child; + } + return child.clone().type(itemTypeReference(itemType)); + } + + private Node itemTypeReference(Node itemType) { + if (itemType.getBlueId() != null) { + return new Node().blueId(itemType.getBlueId()); + } + return itemType.clone(); + } + private Node withoutPosition(Node node) { Node clone = node.clone(); clone.position(null); diff --git a/src/main/java/blue/language/model/Node.java b/src/main/java/blue/language/model/Node.java index 5e4cb3b..d5a539c 100644 --- a/src/main/java/blue/language/model/Node.java +++ b/src/main/java/blue/language/model/Node.java @@ -72,6 +72,10 @@ public Object getValue() { return value; } + public Object getRawValue() { + return value; + } + public List getItems() { return items; } @@ -324,6 +328,10 @@ public Node getAsNode(String path) { return (Node) get(path); } + public Node getNode(String path) { + return NodePathAccessor.getNode(this, path); + } + public String getAsText(String path) { return (String) get(path); } diff --git a/src/main/java/blue/language/processor/ProcessorExecutionContext.java b/src/main/java/blue/language/processor/ProcessorExecutionContext.java index 9552caa..04754c9 100644 --- a/src/main/java/blue/language/processor/ProcessorExecutionContext.java +++ b/src/main/java/blue/language/processor/ProcessorExecutionContext.java @@ -42,6 +42,10 @@ public String contractKey() { return contractKey; } + public String scopePath() { + return scopePath; + } + public Node contractNode() { return contractNode != null ? contractNode.toNode() : null; } @@ -99,6 +103,20 @@ public Node documentAt(String absolutePointer) { return runtime().nodeAt(absolutePointer); } + public FrozenNode canonicalFrozenAt(String absolutePointer) { + if (absolutePointer == null || absolutePointer.isEmpty()) { + return null; + } + return runtime().canonicalFrozenAt(absolutePointer); + } + + public FrozenNode resolvedFrozenAt(String absolutePointer) { + if (absolutePointer == null || absolutePointer.isEmpty()) { + return null; + } + return runtime().resolvedFrozenAt(absolutePointer); + } + public boolean documentContains(String absolutePointer) { if (absolutePointer == null || absolutePointer.isEmpty()) { return false; diff --git a/src/main/java/blue/language/snapshot/FrozenNode.java b/src/main/java/blue/language/snapshot/FrozenNode.java index 95e6de6..8de24e4 100644 --- a/src/main/java/blue/language/snapshot/FrozenNode.java +++ b/src/main/java/blue/language/snapshot/FrozenNode.java @@ -263,6 +263,11 @@ public FrozenNode item(int index) { public FrozenNode at(String pointer) { List segments = JsonPointer.split(pointer); + return at(segments); + } + + public FrozenNode at(List pointerSegments) { + List segments = pointerSegments != null ? pointerSegments : Collections.emptyList(); if (segments.isEmpty()) { return this; } diff --git a/src/main/java/blue/language/utils/NodePathAccessor.java b/src/main/java/blue/language/utils/NodePathAccessor.java index bc08627..9ffe627 100644 --- a/src/main/java/blue/language/utils/NodePathAccessor.java +++ b/src/main/java/blue/language/utils/NodePathAccessor.java @@ -29,6 +29,21 @@ public static Object get(Node node, String path, Function linkingPro return getRecursive(node, segments, 0, linkingProvider, resolveFinalLink); } + public static Node getNode(Node node, String path) { + if (path == null || !path.startsWith("/")) { + throw new IllegalArgumentException("Invalid path: " + path); + } + if (path.equals("/")) { + return node; + } + + Node current = node; + for (String segment : JsonPointer.split(path)) { + current = getStructuralNodeForSegment(current, segment); + } + return current; + } + private static Object getRecursive(Node node, List segments, int index, Function linkingProvider, boolean resolveFinalLink) { if (index == segments.size() - 1 && !resolveFinalLink) { // Return the node itself for the last segment if we're not resolving the final link @@ -84,6 +99,42 @@ private static Node getNodeForSegment(Node node, String segment, Function items = node.getItems(); + if (items == null || itemIndex >= items.size()) { + throw new IllegalArgumentException("Invalid item index: " + itemIndex); + } + return items.get(itemIndex); + } + + Map properties = node.getProperties(); + if (properties == null || !properties.containsKey(segment)) { + throw new IllegalArgumentException("Property not found: " + segment); + } + return properties.get(segment); + } + private static Node link(Node node, Function linkingProvider) { Node linked = linkingProvider.apply(node); return linked == null ? node : linked; diff --git a/src/main/java/blue/language/utils/NodePathEditor.java b/src/main/java/blue/language/utils/NodePathEditor.java new file mode 100644 index 0000000..e5168b4 --- /dev/null +++ b/src/main/java/blue/language/utils/NodePathEditor.java @@ -0,0 +1,114 @@ +package blue.language.utils; + +import blue.language.model.Node; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class NodePathEditor { + + private NodePathEditor() { + } + + public static Node getOrNull(Node node, String pointer) { + Node current = node; + for (String segment : JsonPointer.split(pointer)) { + if (current == null) { + return null; + } + current = childAtOrNull(current, segment); + } + return current; + } + + public static void put(Node root, String pointer, Node value) { + List segments = JsonPointer.split(pointer); + if (segments.isEmpty()) { + root.replaceWith(value); + return; + } + + Node parent = root; + for (int i = 0; i < segments.size() - 1; i++) { + parent = childAtOrCreate(parent, segments.get(i)); + } + setChild(parent, segments.get(segments.size() - 1), value); + } + + private static Node childAtOrNull(Node node, String segment) { + if ("type".equals(segment)) { + return node.getType(); + } + if ("itemType".equals(segment)) { + return node.getItemType(); + } + if ("keyType".equals(segment)) { + return node.getKeyType(); + } + if ("valueType".equals(segment)) { + return node.getValueType(); + } + if ("blue".equals(segment)) { + return node.getBlue(); + } + if (JsonPointer.isArrayIndexSegment(segment) && node.getItems() != null && !"-".equals(segment)) { + int index = Integer.parseInt(segment); + return index < node.getItems().size() ? node.getItems().get(index) : null; + } + return node.getProperties() != null ? node.getProperties().get(segment) : null; + } + + private static Node childAtOrCreate(Node node, String segment) { + Node child = childAtOrNull(node, segment); + if (child != null) { + return child; + } + child = new Node(); + setChild(node, segment, child); + return child; + } + + private static void setChild(Node node, String segment, Node value) { + if ("type".equals(segment)) { + node.type(value); + return; + } + if ("itemType".equals(segment)) { + node.itemType(value); + return; + } + if ("keyType".equals(segment)) { + node.keyType(value); + return; + } + if ("valueType".equals(segment)) { + node.valueType(value); + return; + } + if ("blue".equals(segment)) { + node.blue(value); + return; + } + if (JsonPointer.isArrayIndexSegment(segment) && !"-".equals(segment)) { + int index = Integer.parseInt(segment); + List items = node.getItems(); + if (items == null) { + items = new ArrayList<>(); + node.items(items); + } + while (items.size() <= index) { + items.add(new Node()); + } + items.set(index, value); + return; + } + Map properties = node.getProperties(); + if (properties == null) { + node.properties(new HashMap<>()); + properties = node.getProperties(); + } + properties.put(segment, value); + } +} diff --git a/src/main/java/blue/language/utils/NodePathSelector.java b/src/main/java/blue/language/utils/NodePathSelector.java new file mode 100644 index 0000000..4efc585 --- /dev/null +++ b/src/main/java/blue/language/utils/NodePathSelector.java @@ -0,0 +1,136 @@ +package blue.language.utils; + +import blue.language.model.Node; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Selects concrete JSON Pointer paths from a node using simple path patterns. + * + *

Patterns are JSON Pointer-like paths. {@code *} matches any property key + * or list index at one level. {@code -} matches every list item at one level, + * which is useful for contract masks such as {@code /products/-/ean}.

+ */ +public final class NodePathSelector { + + private NodePathSelector() { + } + + public static List select(Node root, Collection patterns, Predicate predicate) { + if (root == null || patterns == null || patterns.isEmpty()) { + return new ArrayList<>(); + } + if (predicate == null) { + throw new IllegalArgumentException("predicate must not be null"); + } + + Set selected = new LinkedHashSet<>(); + for (String pattern : patterns) { + select(root, JsonPointer.split(pattern), 0, new ArrayList<>(), predicate, selected); + } + return new ArrayList<>(selected); + } + + private static void select(Node current, + List pattern, + int index, + List currentPath, + Predicate predicate, + Set selected) { + if (current == null) { + return; + } + if (index == pattern.size()) { + if (predicate.test(current)) { + selected.add(JsonPointer.toPointer(currentPath)); + } + return; + } + + String segment = pattern.get(index); + if ("*".equals(segment)) { + traverseAllChildren(current, pattern, index, currentPath, predicate, selected); + return; + } + if ("-".equals(segment)) { + traverseListItems(current, pattern, index, currentPath, predicate, selected); + return; + } + + Node child = childAtOrNull(current, segment); + if (child != null) { + currentPath.add(segment); + select(child, pattern, index + 1, currentPath, predicate, selected); + currentPath.remove(currentPath.size() - 1); + } + } + + private static void traverseAllChildren(Node current, + List pattern, + int index, + List currentPath, + Predicate predicate, + Set selected) { + if (current.getItems() != null) { + traverseListItems(current, pattern, index, currentPath, predicate, selected); + } + if (current.getProperties() != null) { + for (Map.Entry entry : current.getProperties().entrySet()) { + currentPath.add(entry.getKey()); + select(entry.getValue(), pattern, index + 1, currentPath, predicate, selected); + currentPath.remove(currentPath.size() - 1); + } + } + } + + private static void traverseListItems(Node current, + List pattern, + int index, + List currentPath, + Predicate predicate, + Set selected) { + if (current.getItems() == null) { + return; + } + for (int i = 0; i < current.getItems().size(); i++) { + currentPath.add(String.valueOf(i)); + select(current.getItems().get(i), pattern, index + 1, currentPath, predicate, selected); + currentPath.remove(currentPath.size() - 1); + } + } + + private static Node childAtOrNull(Node node, String segment) { + if ("type".equals(segment)) { + return node.getType(); + } + if ("itemType".equals(segment)) { + return node.getItemType(); + } + if ("keyType".equals(segment)) { + return node.getKeyType(); + } + if ("valueType".equals(segment)) { + return node.getValueType(); + } + if ("blue".equals(segment)) { + return node.getBlue(); + } + if (node.getItems() != null && isListIndex(segment)) { + int index = Integer.parseInt(segment); + return index < node.getItems().size() ? node.getItems().get(index) : null; + } + return node.getProperties() != null ? node.getProperties().get(segment) : null; + } + + private static boolean isListIndex(String segment) { + return segment != null + && !segment.isEmpty() + && segment.chars().allMatch(Character::isDigit); + } +} diff --git a/src/main/java/blue/language/utils/limits/ExcludedPathLimits.java b/src/main/java/blue/language/utils/limits/ExcludedPathLimits.java new file mode 100644 index 0000000..dc691c9 --- /dev/null +++ b/src/main/java/blue/language/utils/limits/ExcludedPathLimits.java @@ -0,0 +1,91 @@ +package blue.language.utils.limits; + +import blue.language.model.Node; +import blue.language.utils.JsonPointer; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Stack; +import java.util.stream.Collectors; + +/** + * Prevents merge/extension work at specific JSON Pointer paths. + * + *

This is intentionally contract-agnostic. Callers decide which authored + * subtrees need to be preserved for later runtime processing; the language + * resolver only skips those paths.

+ */ +public class ExcludedPathLimits implements Limits { + private final Set excludedPaths; + private final Stack currentPath = new Stack<>(); + private final Stack enteredPathSegment = new Stack<>(); + + public ExcludedPathLimits(Collection excludedPaths) { + this.excludedPaths = excludedPaths == null + ? new HashSet<>() + : excludedPaths.stream() + .map(JsonPointer::canonicalize) + .collect(Collectors.toSet()); + } + + public static ExcludedPathLimits excluding(Collection excludedPaths) { + return new ExcludedPathLimits(excludedPaths); + } + + @Override + public boolean shouldExtendPathSegment(String pathSegment, Node currentNode) { + return !isExcluded(potentialPath(pathSegment)); + } + + @Override + public boolean shouldMergePathSegment(String pathSegment, Node currentNode) { + return !isExcluded(potentialPath(pathSegment)); + } + + @Override + public void enterPathSegment(String pathSegment, Node currentNode) { + boolean realSegment = pathSegment != null && !pathSegment.isEmpty(); + enteredPathSegment.push(realSegment); + if (realSegment) { + currentPath.push(pathSegment); + } + } + + @Override + public void exitPathSegment() { + if (enteredPathSegment.isEmpty()) { + return; + } + if (enteredPathSegment.pop() && !currentPath.isEmpty()) { + currentPath.pop(); + } + } + + private List potentialPath(String pathSegment) { + List potentialPath = new ArrayList<>(currentPath); + if (pathSegment != null && !pathSegment.isEmpty()) { + potentialPath.add(pathSegment); + } + return potentialPath; + } + + private boolean isExcluded(List path) { + String pointer = JsonPointer.toPointer(path); + for (String excludedPath : excludedPaths) { + if (pointer.equals(excludedPath) || isDescendantOf(pointer, excludedPath)) { + return true; + } + } + return false; + } + + private boolean isDescendantOf(String pointer, String ancestor) { + if ("/".equals(ancestor)) { + return true; + } + return pointer.startsWith(ancestor + "/"); + } +} diff --git a/src/test/java/blue/language/MaskedResolutionTest.java b/src/test/java/blue/language/MaskedResolutionTest.java new file mode 100644 index 0000000..5b0a6df --- /dev/null +++ b/src/test/java/blue/language/MaskedResolutionTest.java @@ -0,0 +1,274 @@ +package blue.language; + +import blue.language.model.Node; +import blue.language.provider.BasicNodeProvider; +import blue.language.utils.limits.PathLimits; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; +import static blue.language.utils.Properties.LIST_TYPE_BLUE_ID; +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; +import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MaskedResolutionTest { + + @Test + void normalResolutionStillRejectsAuthoredScalarWhereTypeRequiresList() { + ContractTypes types = contractTypes(); + Blue blue = new Blue(types.provider); + + Node document = blue.yamlToNode( + "contracts:\n" + + " apply:\n" + + " type:\n" + + " blueId: " + types.maskedContractId + "\n" + + " payload: \"${steps.Prepare.payload}\""); + + assertThrows(IllegalArgumentException.class, () -> blue.resolve(document)); + } + + @Test + void preservedPathKeepsExpressionValueWithoutMergingDeclaredListType() { + ContractTypes types = contractTypes(); + Blue blue = new Blue(types.provider); + + Node document = blue.yamlToNode( + "contracts:\n" + + " apply:\n" + + " type:\n" + + " blueId: " + types.maskedContractId + "\n" + + " payload: \"${steps.Prepare.payload}\""); + + Node resolved = blue.resolvePreservingPaths(document, + Collections.singleton("/contracts/apply/payload")); + Node apply = resolved.getAsNode("/contracts/apply"); + Node payload = apply.getProperties().get("payload"); + + assertEquals("${steps.Prepare.payload}", payload.getValue()); + assertEquals(TEXT_TYPE_BLUE_ID, payload.getType().getBlueId()); + assertNull(payload.getItemType()); + assertNull(payload.getItems()); + assertEquals("inherited", apply.getProperties().get("label").getValue()); + } + + @Test + void preservedPathsUseJsonPointerEscaping() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleDocs( + "name: Escaped Contract\n" + + "\"a/b\":\n" + + " type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + "regular: inherited"); + String typeId = provider.getBlueIdByName("Escaped Contract"); + Blue blue = new Blue(provider); + + Node document = blue.yamlToNode( + "type:\n" + + " blueId: " + typeId + "\n" + + "\"a/b\": \"${deferred.list}\""); + + assertThrows(IllegalArgumentException.class, () -> blue.resolve(document.clone())); + + Node resolved = blue.resolvePreservingPaths(document, Collections.singleton("/a~1b")); + + assertEquals("${deferred.list}", resolved.getProperties().get("a/b").getValue()); + assertEquals("inherited", resolved.getProperties().get("regular").getValue()); + } + + @Test + void preservedResolutionCanCombineWithNormalPathLimits() { + ContractTypes types = contractTypes(); + Blue blue = new Blue(types.provider); + + Node document = node( + "contracts:\n" + + " apply:\n" + + " type:\n" + + " blueId: " + types.maskedContractId + "\n" + + " payload: \"${steps.Prepare.payload}\"\n" + + " untouched:\n" + + " type:\n" + + " blueId: " + types.maskedContractId + "\n" + + " payload:\n" + + " - amount: 1\n" + + " memo: ok"); + + Node resolved = blue.resolvePreservingPaths( + document, + PathLimits.withSinglePath("/contracts/apply"), + Collections.singleton("/contracts/apply/payload")); + + Node apply = resolved.getAsNode("/contracts/apply"); + assertEquals("${steps.Prepare.payload}", apply.getProperties().get("payload").getValue()); + assertFalse(resolved.getAsNode("/contracts").getProperties().containsKey("untouched")); + } + + @Test + void matchingPathPatternsPreserveOnlyExpressionLeavesInsideAList() { + ProductTypes types = productTypes(); + Blue blue = new Blue(types.provider); + List patterns = Arrays.asList("/products", "/products/-/ean"); + + Node document = blue.yamlToNode( + "type:\n" + + " blueId: " + types.inventoryId + "\n" + + "products:\n" + + " - name: product 1\n" + + " ean: \"${event.ean}\""); + + assertEquals(Collections.singletonList("/products/0/ean"), + blue.selectPaths(document, patterns, this::isExpressionText)); + + Node resolved = blue.resolvePreservingMatchingPaths(document, patterns, this::isExpressionText); + Node products = resolved.getProperties().get("products"); + Node product = products.getItems().get(0); + Node ean = product.getProperties().get("ean"); + + assertEquals(LIST_TYPE_BLUE_ID, products.getType().getBlueId()); + assertEquals(types.productId, products.getItemType().getBlueId()); + assertEquals(types.productId, product.getType().getBlueId()); + assertEquals("${event.ean}", ean.getValue()); + assertEquals(TEXT_TYPE_BLUE_ID, ean.getType().getBlueId()); + } + + @Test + void matchingPathPatternsKeepLiteralListFullyValidatedWhenNoNodesMatchPredicate() { + ProductTypes types = productTypes(); + Blue blue = new Blue(types.provider); + List patterns = Arrays.asList("/products", "/products/-/ean"); + + Node document = blue.yamlToNode( + "type:\n" + + " blueId: " + types.inventoryId + "\n" + + "products:\n" + + " - name: product 1\n" + + " ean: 1234"); + + assertTrue(blue.selectPaths(document, patterns, this::isExpressionText).isEmpty()); + + Node resolved = blue.resolvePreservingMatchingPaths(document, patterns, this::isExpressionText); + Node product = resolved.getAsNode("/products").getItems().get(0); + Node ean = product.getProperties().get("ean"); + + assertEquals(types.productId, product.getType().getBlueId()); + assertEquals(INTEGER_TYPE_BLUE_ID, ean.getType().getBlueId()); + assertEquals(new BigInteger("1234"), ean.getValue()); + } + + @Test + void matchingPathPatternsDoNotPreserveInvalidNonExpressionLeaf() { + ProductTypes types = productTypes(); + Blue blue = new Blue(types.provider); + List patterns = Arrays.asList("/products", "/products/-/ean"); + + Node document = blue.yamlToNode( + "type:\n" + + " blueId: " + types.inventoryId + "\n" + + "products:\n" + + " - name: product 1\n" + + " ean: not-a-number"); + + assertTrue(blue.selectPaths(document, patterns, this::isExpressionText).isEmpty()); + assertThrows(IllegalArgumentException.class, + () -> blue.resolvePreservingMatchingPaths(document, patterns, this::isExpressionText)); + } + + private Node node(String yaml) { + return YAML_MAPPER.readValue(yaml, Node.class); + } + + private boolean isExpressionText(Node node) { + Object value = node.getRawValue(); + if (!(value instanceof String)) { + return false; + } + String text = ((String) value).trim(); + return text.startsWith("${") && text.endsWith("}") && text.length() > 3; + } + + private ContractTypes contractTypes() { + BasicNodeProvider provider = new BasicNodeProvider(); + + provider.addSingleDocs( + "name: Patch Entry\n" + + "amount:\n" + + " type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID + "\n" + + "memo:\n" + + " type: Text"); + + String patchEntryId = provider.getBlueIdByName("Patch Entry"); + + provider.addSingleDocs( + "name: Masked Contract\n" + + "payload:\n" + + " type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + " itemType:\n" + + " blueId: " + patchEntryId + "\n" + + "label: inherited"); + + return new ContractTypes( + provider, + provider.getBlueIdByName("Masked Contract")); + } + + private ProductTypes productTypes() { + BasicNodeProvider provider = new BasicNodeProvider(); + + provider.addSingleDocs( + "name: Product\n" + + "ean:\n" + + " type:\n" + + " blueId: " + INTEGER_TYPE_BLUE_ID); + + String productId = provider.getBlueIdByName("Product"); + + provider.addSingleDocs( + "name: Product Inventory\n" + + "products:\n" + + " type:\n" + + " blueId: " + LIST_TYPE_BLUE_ID + "\n" + + " itemType:\n" + + " blueId: " + productId + "\n" + + "status: open"); + + return new ProductTypes( + provider, + productId, + provider.getBlueIdByName("Product Inventory")); + } + + private static final class ContractTypes { + private final BasicNodeProvider provider; + private final String maskedContractId; + + private ContractTypes(BasicNodeProvider provider, String maskedContractId) { + this.provider = provider; + this.maskedContractId = maskedContractId; + } + } + + private static final class ProductTypes { + private final BasicNodeProvider provider; + private final String productId; + private final String inventoryId; + + private ProductTypes(BasicNodeProvider provider, String productId, String inventoryId) { + this.provider = provider; + this.productId = productId; + this.inventoryId = inventoryId; + } + } +} From 64c59289a8eb29542bfb9c8b775fee258a29c0ca Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Wed, 20 May 2026 21:10:34 +0200 Subject: [PATCH 2/2] chore: bump version to 2.0.1 --- .cz.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cz.toml b/.cz.toml index 29117b5..ab20c79 100644 --- a/.cz.toml +++ b/.cz.toml @@ -2,5 +2,5 @@ name = "cz_conventional_commits" tag_format = "v$version" version_scheme = "semver" -version = "2.0.0" +version = "2.0.1" update_changelog_on_bump = true