private static void mergeNodeTrees(final JsonNode jsonNode,
                                       final JsonNode parentJsonNode,
                                       final String name,
                                       final StringBuilder indentBuilder,
                                       final PropertyPath propertyPath,
                                       final JsonNode sourceRootNode) {
        final String jsonPointerExpr = "/" + propertyPath.delimitedBy("/");
        final JsonNode equivalentSourceNode = propertyPath.isBlank()
                ? sourceRootNode
                : sourceRootNode.at(jsonPointerExpr);

//        System.out.println(LogUtil.message("{}name: {}, type: {}, jsonPointerExpr: {}, source type: {}",
//                indent,
//                name,
//                jsonNode.getNodeType(),
//                jsonPointerExpr,
//                equivalentSourceNode.getNodeType()));
        LOGGER.trace("{}name: {}, type: {}, jsonPointerExpr: {}, source type: {}",
                indentBuilder,
                name,
                jsonNode.getNodeType(),
                jsonPointerExpr,
                equivalentSourceNode.getNodeType());

        if (JsonNodeType.OBJECT.equals(jsonNode.getNodeType())) {
            final Set<String> childFieldNames = new HashSet<>();
            jsonNode.fieldNames().forEachRemaining(childFieldNames::add);

            equivalentSourceNode.fieldNames().forEachRemaining(childFieldName -> {
                LOGGER.trace("{}Field: {}", indentBuilder, childFieldName);
                if (!childFieldNames.contains(childFieldName)) {
                    // Add field that is in the source node tree but not in ours
                    // I.e. assuming the absence of a field is not an explicit null
                    final JsonNode sourceNode = equivalentSourceNode.get(childFieldName);
                    LOGGER.trace("{}Adding missing field {} from source", indentBuilder, childFieldName);
                    ((ObjectNode) jsonNode).set(childFieldName, sourceNode);
                } else {
                    // field is in both so recurse in
                    final JsonNode childNode = jsonNode.get(childFieldName);
                    final PropertyPath childPropertyPath = propertyPath.merge(childFieldName);
                    LOGGER.trace("{}Recursing into field {}", indentBuilder, childFieldName);
                    mergeNodeTrees(
                            childNode,
                            jsonNode,
                            childFieldName,
                            indentBuilder.append("  "),
                            childPropertyPath,
                            sourceRootNode);
                }
            });

            jsonNode.fields().forEachRemaining(entry -> {
            });
        } else if (JsonNodeType.NULL.equals(jsonNode.getNodeType())) {
            if (equivalentSourceNode.isMissingNode()) {
                throw new RuntimeException("Can't find node " + jsonPointerExpr + " in source tree");
            }

            if (equivalentSourceNode.isObject() || equivalentSourceNode.isArray()) {
                // copy the source node into our parent
//                System.out.println(LogUtil.message("{}Replacing value of this field from source", indent));
                LOGGER.trace("{}Replacing value of this field from source", indentBuilder);
                ((ObjectNode) parentJsonNode).replace(name, equivalentSourceNode);
            } else {
//                System.out.println(LogUtil.message("{}Treating this node as an explicit null", indent));
                LOGGER.trace("{}Treating this node as an explicit null", indentBuilder);
            }
        }
    }
Merges a sparse JsonNode tree with a fully populated default jsonNode tree. Missing nodes will be added.
Nodes that are null and are a branch in the default tree are replaced with the corresponding node from
the default tree. Nodes that are null but are a leaf in the default tree are left as is and treated as
explicit nulls.

@param valueType           The POJO type to convert the merged yaml into.
@param yamlObjectMapper    The {@link ObjectMapper} to use for (de)serialisation.
@param sparseTreeProvider  A function to produce a {@link JsonNode} tree of the sparse yaml. Allows you to
                           create the node tree from file/string/stream/etc.
@param defaultTreeProvider A function to produce a {@link JsonNode} tree of the default yaml. Allows you to*
                           create the node tree from file/string/stream/etc.
@param <T>                 The POJO type to convert the merged yaml into.
@return The merged yaml de-serialised into T.
stroom-util/src/main/java/stroom/util/yaml/YamlUtil.java