/* * Copyright 2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.springframework.xd.dirt.job.dsl; import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.ObjectMapper; /** * Represents a Graph that Flo will display. A graph consists of simple nodes and links. * * @author Andy Clement */ @JsonIgnoreProperties(ignoreUnknown = true) public class Graph { public List<Node> nodes; public List<Link> links; public Map<String, String> properties; Graph() { this.nodes = new ArrayList<>(); this.links = new ArrayList<>(); this.properties = null; } Graph(List<Node> nodes, List<Link> links, Map<String, String> properties) { this.nodes = nodes; this.links = links; this.properties = (properties == null || properties.size() == 0) ? null : properties; } public List<Node> getNodes() { return this.nodes; } public List<Link> getLinks() { return this.links; } public Map<String, String> getProperties() { return this.properties; } @Override public String toString() { return "Graph: nodes=#" + nodes.size() + " links=#" + links.size() + "\n" + nodes + "\n" + links; } public String toJSON() { Graph g = new Graph(nodes, links, properties); ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(Include.NON_NULL); try { return mapper.writeValueAsString(g); } catch (IOException e) { throw new IllegalStateException("Unexpected problem creating JSON from Graph", e); } } /** * Produce the DSL representation of the graph. * To make this process easier we can assume there is a START and an END node. * * @return DSL string version of the graph */ public String toDSLText() { StringBuilder graphText = new StringBuilder(); List<Node> unvisitedNodes = new ArrayList<>(); List<Link> unfollowedLinks = new ArrayList<>(); unvisitedNodes.addAll(nodes); unfollowedLinks.addAll(links); Node start = findNodeByName("START"); unvisitedNodes.remove(start); Node end = findNodeByName("END"); unvisitedNodes.remove(end); Node fail = findNodeByName("FAIL"); if (fail != null) { unvisitedNodes.remove(fail); } if (start == null || end == null) { throw new IllegalStateException("Graph is malformed - problems finding START and END nodes"); } List<Link> toFollow = findLinksFrom(start, false); // This will build the main part of the DSL text based on walking the graph followLinks(graphText, toFollow, null, unvisitedNodes, unfollowedLinks); // This will follow up any loose ends that were not reachable down the regular path // from the START node (eg. reachable only by transition). // For example: aa | foo=bb | '*' = cc || bb || cc // There is no implied link from aa to bb because aa is mapping the exit space // so there is no implied transition 'COMPLETED=bb'. bb can only be reached via // transition. For that case unvisitedNodes here will contain bb if (unvisitedNodes.size() != 0) { int loopCount = 0; while (unvisitedNodes.size() != 0 && loopCount < 10000) { Node nextHead = findAHead(unvisitedNodes, unfollowedLinks); unvisitedNodes.remove(nextHead); toFollow = findLinksFrom(nextHead, false); // If the new head we find has no links to anything, we don't need to mention it in the DSL. // Transitions will refer to it and it will get a step in the XML but there is no need // to explicitly mention in the DSL. This might change once the job references support properties. if (toFollow.size() != 0) { graphText.append(" || "); printNode(graphText, nextHead, unvisitedNodes); followLinks(graphText, toFollow, null, unvisitedNodes, unfollowedLinks); } loopCount++; // Just a guard on malformed input - a good graph will not trigger this } } if (properties != null) { for (Map.Entry<String, String> property : properties.entrySet()) { graphText.append(" --").append(property.getKey()).append("=").append(property.getValue()); } } return graphText.toString(); } private Node findAHead(List<Node> unvisitedNodes, List<Link> unvisitedLinks) { if (unvisitedNodes.size() == 0) { return null; } Node candidate = unvisitedNodes.get(0); boolean changedCandidate = true; while (changedCandidate) { changedCandidate = false; for (Link link : unvisitedLinks) { if (link.to == candidate.id) { changedCandidate = true; candidate = findNodeById(link.from); } } } return candidate; } /** * Chase down links, populating the graphText as it proceeds. * * @param graphText where to place the DSL text as we process the graph * @param toFollow the links to follow * @param nodeToTerminateFollow the node that should trigger termination of following */ private void followLinks(StringBuilder graphText, List<Link> toFollow, Node nodeToTerminateFollow, List<Node> unvisitedNodes, List<Link> unfollowedLinks) { while (toFollow.size() != 0) { if (toFollow.size() > 1) { // SPLIT if (graphText.length() != 0) { // If there is something already in the text, a || is needed to // join it to the preceding element graphText.append(" || "); } graphText.append("<"); Node endOfSplit = findEndOfSplit(toFollow); for (int i = 0; i < toFollow.size(); i++) { if (i > 0) { graphText.append(" & "); } Link l = toFollow.get(i); followLink(graphText, l, endOfSplit, unvisitedNodes, unfollowedLinks); } graphText.append(">"); if (endOfSplit == null || endOfSplit.isEnd()) { // nothing left to do break; } if (endOfSplit == nodeToTerminateFollow) { // Time to finish if termination node hit break; } unvisitedNodes.remove(endOfSplit); if (!endOfSplit.isSync()) { // If not a sync node, include it in the output text graphText.append(" || "); printNode(graphText, endOfSplit, unvisitedNodes); List<Link> transitionalLinks = findLinksFrom(endOfSplit, false); // null final param here probably not correct printTransitions(graphText, unvisitedNodes, unfollowedLinks, transitionalLinks, null); } toFollow = findLinksFromWithoutTransitions(endOfSplit, false); } else if (toFollow.size() == 1) { // FLOW Link linkToFollow = toFollow.get(0); Node linkToFollowTarget = findNodeById(linkToFollow.to); if (linkToFollowTarget != nodeToTerminateFollow) { // need special handling for end/fail?? if (graphText.length() != 0) { // First one doesn't need a || on the front graphText.append(" || "); } followLink(graphText, linkToFollow, nodeToTerminateFollow, unvisitedNodes, unfollowedLinks); } break; } } } private Node findEndOfSplit(List<Link> toFollow) { if (toFollow.size() == 0) { return null; } if (toFollow.size() == 1) { // return the first node... return findNodeById(toFollow.get(0).to); } // Follow the first link. For each node found see if it // exists down the chain of all the other links (i.e. is a common target) Link link = toFollow.get(0); Node nextCandidate = findNodeById(link.to); while (nextCandidate != null) { boolean allLinksLeadToTheCandidate = true; for (int l = 1; l < toFollow.size(); l++) { if (!foundInChain(toFollow.get(l), nextCandidate)) { allLinksLeadToTheCandidate = false; break; } } if (allLinksLeadToTheCandidate) { return nextCandidate; } List<Link> links = findLinksFrom(nextCandidate, true); if (links.size() == 0) { nextCandidate = null; } else if (links.size() == 1) { nextCandidate = findNodeById(links.get(0).to); } else { if (countLinksWithoutTransitions(links) == 0 || countLinksWithoutTransitions(links) == 1) { // Assert: it doesn't therefore matter which one is chosen, they will come together at // the same place nextCandidate = findNodeById(links.get(0).to); } else { while (countLinksWithoutTransitions(links) > 1) { nextCandidate = findEndOfSplit(links); links = findLinksFrom(nextCandidate, true); } } } } // This indicates a broken graph throw new IllegalStateException("Unable to find end of split"); } /** * Walk a specified link to see if it ever hits the candidate node. * * @param link points to the head of a chain of nodes * @param candidate the node possibly found on the chain of nodes * @return true if the candidate is found down the specified chain */ private boolean foundInChain(Link link, Node candidate) { String targetId = link.to; Node targetNode = findNodeById(targetId); if (targetNode == candidate) { return true; } // This algorithm relies on a nicely structured graph with well defined flows and splits (no weird cross links // across flows/splits) List<Link> outboundLinks = findLinksFrom(targetNode, true); for (Link lnk : outboundLinks) { if (foundInChain(lnk, candidate)) { return true; } } return false; } private int countLinksWithoutTransitions(List<Link> links) { int count = 0; for (Link link : links) { if (!link.hasTransitionSet()) { count++; } } return count; } private void printNode(StringBuilder graphText, Node node, List<Node> unvisitedNodes) { unvisitedNodes.remove(node); // What to generate depends on whether it is a job definition or reference if (node.metadata != null && node.metadata.containsKey(Node.METADATAKEY_JOBMODULENAME)) { graphText.append(node.metadata.get(Node.METADATAKEY_JOBMODULENAME)).append(" "); graphText.append(node.name).append(" "); if (node.properties != null) { int count = 0; for (Map.Entry<String, String> entry : node.properties.entrySet()) { if (count > 0) { graphText.append(" "); } graphText.append("--").append(entry.getKey()).append("=").append(entry.getValue()); count++; } } } else { String nameInDSL = node.name; graphText.append(nameInDSL); if (node.properties != null) { for (Map.Entry<String, String> entry : node.properties.entrySet()) { graphText.append(" "); graphText.append("--").append(entry.getKey()).append("=").append(entry.getValue()); } } } } private void followLink(StringBuilder graphText, Link link, Node nodeToFinishFollowingAt, List<Node> unvisitedNodes, List<Link> unfollowedLinks) { unfollowedLinks.remove(link); Node target = findNodeById(link.to); printNode(graphText, target, unvisitedNodes); List<Link> toFollow = findLinksFrom(target, false); printTransitions(graphText, unvisitedNodes, unfollowedLinks, toFollow, nodeToFinishFollowingAt); followLinks(graphText, toFollow, nodeToFinishFollowingAt, unvisitedNodes, unfollowedLinks); } private void printTransitions(StringBuilder graphText, List<Node> unvisitedNodes, List<Link> unfollowedLinks, List<Link> toFollow, Node nodeToFinishFollowingAt) { for (Iterator<Link> iterator = toFollow.iterator(); iterator.hasNext();) { Link l = iterator.next(); if (l.hasTransitionSet()) { // capture the target of this link as a simple transition String transitionName = l.getTransitionName(); Node transitionTarget = findNodeById(l.to); String transitionTargetName = transitionTarget.name; if (transitionTargetName.equals("FAIL")) { transitionTargetName = Transition.FAIL; } else if (transitionTargetName.equals("END")) { transitionTargetName = Transition.END; } graphText.append(" | ").append(transitionName).append(" = ").append(transitionTargetName); unfollowedLinks.remove(l); // We only want to consider it 'visited' if this node doesn't go anywhere after this List<Link> linksFromTheTransitionTarget = findLinksFrom(transitionTarget, false); if (linksFromTheTransitionTarget.isEmpty() || allLinksTarget(linksFromTheTransitionTarget, nodeToFinishFollowingAt)) { unvisitedNodes.remove(transitionTarget); } iterator.remove(); } } } private boolean allLinksTarget(List<Link> linksFromTheTransitionTarget, Node nodeToFinishFollowingAt) { if (nodeToFinishFollowingAt == null) { return false; } for (Link link : linksFromTheTransitionTarget) { if (!link.to.equals(nodeToFinishFollowingAt.id)) { return false; } } return true; } private Node findNodeById(String id) { for (Node n : nodes) { if (n.id.equals(id)) { return n; } } return null; } private Node findNodeByName(String name) { for (Node n : nodes) { if (n.name.equals(name)) { return n; } } return null; } private List<Link> findLinksFromWithoutTransitions(Node n, boolean includeThoseLeadingToEnd) { List<Link> result = new ArrayList<>(); for (Link link : links) { if (link.from.equals(n.id)) { if ((!link.hasTransitionSet() && (includeThoseLeadingToEnd || !findNodeById(link.to).name.equals("END"))) || (link.hasTransitionSet() && link.getTransitionName().equals("'*'"))) { result.add(link); } } } return result; } private List<Link> findLinksFrom(Node n, boolean includeThoseLeadingToEnd) { List<Link> result = new ArrayList<>(); for (Link link : links) { if (link.from.equals(n.id)) { // Only include links to 'END' if there are properties on it if (includeThoseLeadingToEnd || !(findNodeById(link.to).name.equals("END") && link.properties == null)) { result.add(link); } } } return result; } }