001/**
002 * Copyright 2005-2015 The Kuali Foundation
003 *
004 * Licensed under the Educational Community License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.opensource.org/licenses/ecl2.php
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.kuali.rice.krms.framework.engine;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.Iterator;
025import java.util.LinkedList;
026import java.util.List;
027import java.util.Map;
028import java.util.Map.Entry;
029import java.util.PriorityQueue;
030import java.util.Set;
031
032import org.apache.commons.collections.CollectionUtils;
033import org.apache.commons.lang.ArrayUtils;
034import org.apache.commons.lang.StringUtils;
035import org.kuali.rice.krms.api.engine.Term;
036import org.kuali.rice.krms.api.engine.TermResolutionEngine;
037import org.kuali.rice.krms.api.engine.TermResolutionException;
038import org.kuali.rice.krms.api.engine.TermResolver;
039
040/**
041 * An implementation of {@link TermResolutionEngine}
042 * @author Kuali Rice Team (rice.collab@kuali.org)
043 */
044public class TermResolutionEngineImpl implements TermResolutionEngine {
045        private static final org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(TermResolutionEngineImpl.class);
046        
047        private final Map<String, List<TermResolver<?>>> termResolversByOutput = new HashMap<String, List<TermResolver<?>>>();
048        private final Map<TermResolverKey, TermResolver<?>> termResolversByKey = new HashMap<TermResolverKey, TermResolver<?>>(); 
049        
050        // should this use soft refs?  Will require some refactoring to check if the referenced object is around;
051        private final Map<Term, Object> termCache = new HashMap<Term, Object>();
052
053        @Override
054        public void addTermValue(Term term, Object value) {
055                termCache.put(term, value);
056        }
057        
058        @Override
059        public void addTermResolver(TermResolver<?> termResolver) {
060                if (termResolver == null) throw new IllegalArgumentException("termResolver is reuqired");
061                if (termResolver.getOutput() == null) throw new IllegalArgumentException("termResolver.getOutput() must not be null");
062
063                List<TermResolver<?>> termResolvers = termResolversByOutput.get(termResolver.getOutput());
064                if (termResolvers == null) {
065                        termResolvers = new LinkedList<TermResolver<?>>();
066                        termResolversByOutput.put(termResolver.getOutput(), termResolvers);
067                }
068                termResolversByKey.put(new TermResolverKey(termResolver), termResolver);
069                termResolvers.add(termResolver);
070        }
071
072        @SuppressWarnings("unchecked")
073        @Override
074        public <T> T resolveTerm(Term term) throws TermResolutionException {
075                LOG.debug("+--> resolveTerm(" + term + ")");
076                if (termCache.containsKey(term)) return (T)termCache.get(term);
077                
078                String termName = term.getName();
079                
080                // build plan w/ termName spec for correct TermResolver selection
081                List<TermResolverKey> resolutionPlan = buildTermResolutionPlan(termName);
082                // TODO: could cache these plans somewhere, since future agendas will likely require the same plans
083                
084                LOG.debug("resolutionPlan: " + (resolutionPlan == null ? "null" : StringUtils.join(resolutionPlan.iterator(), ", ")));
085                
086                if (resolutionPlan != null) {
087                        LOG.debug("executing plan");
088                        for (TermResolverKey resolverKey : resolutionPlan) {
089                                TermResolver<?> resolver = termResolversByKey.get(resolverKey);
090                                
091                                // build prereqs
092                                Map<String, Object> resolvedPrereqs = new HashMap<String, Object>();
093                                
094                                // The plan order should guarantee these prereqs exist and are cached.
095                                for (String prereq : resolver.getPrerequisites()) {
096                                        Object resolvedPrereq = termCache.get(new Term(prereq, null));
097                                        resolvedPrereqs.put(prereq, resolvedPrereq);
098                                }
099                                
100                                Map<String, String> providedParameters = Collections.emptyMap();
101                                // The destination Term (and only the dest Term) can be parameterized
102                                if (termName.equals(resolver.getOutput())) {
103                                        providedParameters = term.getParameters();
104                                        
105                                        // throw a TermResolutionException if the params doen't match up
106                                        validateTermParameters(resolver, providedParameters);
107                                }
108                                
109                                Object resolvedTerm = resolver.resolve(resolvedPrereqs, providedParameters);
110                                if (termName.equals(resolver.getOutput())) {
111                                        termCache.put(term, resolvedTerm);
112                                } else {
113                                        if (!CollectionUtils.isEmpty(resolver.getParameterNames())) {
114                                                // Shouldn't happen due to checks in buildResolutionPlan
115                                                throw new TermResolutionException("TermResolvers requiring parameters cannot be intermediates in the Term resolution plan", resolver, providedParameters);
116                                        }
117                                        termCache.put(new Term(resolver.getOutput(), null), resolvedTerm);
118                                }
119                        }               
120                } else {
121                        throw new TermResolutionException("Unable to plan the resolution of " + term, null, null);
122                }
123                return (T)termCache.get(term);
124        }
125
126        /**
127         * This method checks that the required parameters (as returned by the {@link TermResolver} via 
128         * {@link TermResolver#getParameterNames()}) are met in the {@link Map} of provided parameters.
129         * 
130         * @param resolver {@link TermResolver}
131         * @param providedParameters
132         * @throws TermResolutionException if provided parameters do not match requirements
133         */
134        private void validateTermParameters(TermResolver<?> resolver,
135                        Map<String, String> providedParameters)
136                        throws TermResolutionException {
137                // validate that params match what the TermResolver requires
138                if (!providedParameters.keySet().equals(resolver.getParameterNames())) {
139                        StringBuilder sb = new StringBuilder();
140
141                        boolean first = true;
142                        for (Entry<String,String> param : providedParameters.entrySet()) {
143                                if (first) {
144                                        first = false;
145                                } else {
146                                        sb.append(",");
147                                }
148                                sb.append(param.getKey());
149                                sb.append("=");
150                                sb.append(param.getValue());
151                        }
152
153                        throw new TermResolutionException("provided parameters ("+ sb
154                                        +") do not match requirements ("
155                                        + StringUtils.join(resolver.getParameterNames(), ",") +")", resolver, providedParameters);
156                }
157        }
158
159    /**
160     *
161     * @param termName
162     * @return List<{@link TermResolverKey}>
163     */
164        protected List<TermResolverKey> buildTermResolutionPlan(String termName) {
165                // our result
166                List<TermResolverKey> resolutionPlan = null;
167
168                // Holds the resolvers we've visited, along with the needed metadata for generating our final plan
169                Map<TermResolverKey, Visited> visitedByKey = new HashMap<TermResolverKey, Visited>();
170
171                // this holds a least cost first list of nodes remaining to be explored
172                PriorityQueue<ToVisit> toVisits = new PriorityQueue<ToVisit>(); // nice grammar there cowboy
173
174                // dummy resolver to be the root of this tree
175                // Do I really need this?  Yes, because there may be more than one resolver that resolves to the desired termName,
176                // so this destination unifies the trees of those candidate resolvers
177                TermResolver destination = createDestination(termName); // problem is we can't get this one out of the registry
178                TermResolverKey destinationKey = new TermResolverKey(destination);
179
180                LOG.debug("Beginning resolution tree search for " + termName);
181
182                // seed our queue of resolvers to visit
183                // need to be aware of null parent for root ToVisit
184                toVisits.add(new ToVisit(0, destination, null));
185
186                // there may not be a viable plan
187                boolean plannedToDestination = false;
188
189                // We'll do a modified Dijkstra's shortest path algorithm, where at each leaf we see if we've planned out
190                // termName resolution all the way up to the root, our destination.  If so, we just reconstruct our plan.
191                while (!plannedToDestination && toVisits.size() > 0) {
192                        // visit least cost node remaining
193                        ToVisit visiting = toVisits.poll();
194
195                        LOG.debug("visiting " + visiting.getTermResolverKey());
196
197                        // the resolver is the edge in our tree -- we don't get it directly from the termResolversByKey Map, because it could be our destination
198                        TermResolver resolver = getResolver(visiting.getTermResolverKey(), destination, destinationKey);
199                        TermResolver parent = getResolver(visiting.getParentKey(), destination, destinationKey);
200
201                        if (visitedByKey.containsKey(visiting.getTermResolverKey())) {
202                                continue; // We've already visited this one
203                        }
204
205                        Visited parentVisited = visitedByKey.get(visiting.getParentKey());
206
207                        if (resolver == null) throw new RuntimeException("Unable to get TermResolver by its key");
208                        Set<String> prereqs = resolver.getPrerequisites();
209                        // keep track of any prereqs that we already have handy
210                        List<String> metPrereqs = new LinkedList<String>();
211
212                        // see what prereqs we have already, and which we'll need to visit
213                        if (prereqs != null) for (String prereq : prereqs) {
214                                if (!termCache.containsKey(new Term(prereq, null))) {
215                                        // enqueue all resolvers in toVisits
216                                        List<TermResolver<?>> prereqResolvers = termResolversByOutput.get(prereq);
217                                        if (prereqResolvers != null) for (TermResolver prereqResolver : prereqResolvers) {
218                                                // Only TermResolvers that don't take paramaterized terms can be chained, so:
219                                                // if the TermResolver doesn't take parameters, or it resolves the output termName
220                                                if (CollectionUtils.isEmpty(prereqResolver.getParameterNames()) ||
221                                                                termName.equals(prereqResolver.getOutput()))
222                                                {
223                                                        // queue it up for visiting
224                                                        toVisits.add(new ToVisit(visiting.getCost() /* cost to get to this resolver */, prereqResolver, resolver));
225                                                }
226                                        }
227                                } else {
228                                        metPrereqs.add(prereq);
229                                }
230                        }
231
232                        // Build visited info
233                        Visited visited = buildVisited(resolver, parentVisited, metPrereqs);
234                        visitedByKey.put(visited.getResolverKey(), visited);
235
236                        plannedToDestination = isPlannedBackToDestination(visited, destinationKey, visitedByKey);
237                }
238
239                if (plannedToDestination) {
240                        // build result from Visited tree.
241                        resolutionPlan = new LinkedList<TermResolverKey>();
242
243                        assembleLinearResolutionPlan(visitedByKey.get(destinationKey), visitedByKey, resolutionPlan);
244                }
245                return resolutionPlan;
246        }
247
248        /**
249         *  @return the Visited object for the resolver we just, er, well, visited.
250         */
251        private Visited buildVisited(TermResolver resolver, Visited parentVisited, Collection<String> metPrereqs) {
252                Visited visited = null;
253
254                List<TermResolverKey> pathTo = new ArrayList<TermResolverKey>(1 + (parentVisited == null ? 0 : parentVisited.pathTo.size()));
255                if (parentVisited != null && parentVisited.getPathTo() != null) pathTo.addAll(parentVisited.getPathTo());
256                if (parentVisited != null) pathTo.add(parentVisited.getResolverKey());
257                TermResolverKey resolverKey = new TermResolverKey(resolver);
258
259                visited = new Visited(resolverKey, pathTo, resolver.getPrerequisites(), resolver.getCost() + (parentVisited == null ? 0 : parentVisited.getCost()));
260                for (String metPrereq : metPrereqs) { visited.addPlannedPrereq(metPrereq); }
261
262                return visited;
263        }
264
265        /**
266         * our dummy destination isn't allowed to pollute termResolversByKey, hence the ugly conditional encapsulated herein
267         */
268        private TermResolver getResolver(TermResolverKey resolverKey,
269                        TermResolver destination, TermResolverKey destinationKey) {
270                TermResolver resolver;
271                if (destinationKey.equals(resolverKey)) {
272                        resolver = destination;
273                } else {
274                        resolver = termResolversByKey.get(resolverKey);
275                }
276                return resolver;
277        }
278
279        /**
280         * @param visited
281         * @param destinationKey
282         * @param visitedByKey
283         * @return
284         */
285        private boolean isPlannedBackToDestination(Visited visited,
286                        TermResolverKey destinationKey,
287                        Map<TermResolverKey, Visited> visitedByKey) {
288                boolean plannedToDestination = false;
289                if (visited.isFullyPlanned()) {
290                        LOG.debug("Leaf! this resolver's prereqs are all avialable.");
291                        // no traversing further yet, instead we need to check how far up the tree is fully planned out
292                        // step backwards toward the root of the tree and see if we're good all the way to our objective.
293                        // if a node fully planned, move up the tree (towards the root) to see if its parent is fully planned, and so on.
294
295                        if (visited.getPathTo().size() > 0) {
296                                // reverse the path to
297                                List<TermResolverKey> reversePathTo = new ArrayList<TermResolverKey>(visited.getPathTo());
298                                Collections.reverse(reversePathTo);
299
300                                // we use this to propagate resolutions up the tree
301                                Visited previousAncestor = visited;
302
303                                for (TermResolverKey ancestorKey : reversePathTo) {
304
305                                        Visited ancestorVisited = visitedByKey.get(ancestorKey);
306                                        ancestorVisited.addPlannedPrereq(previousAncestor.getResolverKey());
307
308                                        LOG.debug("checking ancestor " + ancestorKey);
309
310                                        if (ancestorVisited.isFullyPlanned() && ancestorKey.equals(destinationKey)) {
311                                                // Woot! Job's done!
312                                                plannedToDestination = true;
313                                                break;
314                                        } else if (!ancestorVisited.isFullyPlanned()) { // if the ancestor isn't fully planned, we're done
315                                                LOG.debug("Still have planning to do.");
316                                                break;
317                                        }
318                                        // update previousAncestor reference for next iteration
319                                        previousAncestor = ancestorVisited;
320                                }
321                        } else {
322                                // we're done already! do a jig?
323                                LOG.debug("Trivial plan.");
324                                plannedToDestination = true;
325                        }
326                }
327                return plannedToDestination;
328        }
329
330    /**
331     *
332     * @param visited
333     * @param visitedByKey
334     * @param plan
335     */
336        private void assembleLinearResolutionPlan(Visited visited, Map<TermResolverKey, Visited> visitedByKey, List<TermResolverKey> plan) {
337                // DFS
338                for (TermResolverKey prereqResolverKey : visited.getPrereqResolvers()) {
339                        Visited prereqVisited = visitedByKey.get(prereqResolverKey);
340                        assembleLinearResolutionPlan(prereqVisited, visitedByKey, plan);
341                        plan.add(prereqResolverKey);
342                }
343        }
344
345        /**
346         * Create our dummy destination resolver
347         * @param termName
348         */
349        private TermResolver<? extends Object> createDestination(final String termName) {
350                TermResolver<?> destination = new TermResolver<Object>() {
351                        final String dest = "termResolutionEngineDestination";
352                        @Override
353                        public int getCost() {
354                                return 0;
355                        }
356                        @Override
357                        public String getOutput() {
358                                return dest;
359                        }
360                        @Override
361                        public Set<String> getPrerequisites() {
362                                return Collections.<String>singleton(termName);
363                        }
364                        @Override
365                        public Set<String> getParameterNames() {
366                                return Collections.emptySet();
367                        }
368                        @Override
369                        public Object resolve(Map<String, Object> resolvedPrereqs, Map<String, String> parameters) throws TermResolutionException {
370                                return null;
371                        }
372                };
373                return destination;
374        }
375
376        private static class ToVisit implements Comparable<ToVisit> {
377
378                private final int precost;
379                private final int addcost;
380                private final TermResolverKey resolverKey;
381
382                // the parent key is not being used for comparison purposes currently
383                private final TermResolverKey parentKey;
384
385                /**
386                 * @param precost
387                 * @param resolver
388                 */
389                public ToVisit(int precost, TermResolver resolver, TermResolver parent) {
390                        super();
391                        this.precost = precost;
392                        this.addcost = resolver.getCost();
393                        this.resolverKey = new TermResolverKey(resolver);
394
395                        if (parent != null) {
396                                this.parentKey = new TermResolverKey(parent);
397                        } else {
398                                this.parentKey = null;
399                        }
400                }
401
402        /**
403         * Get the precost plus the addcost
404         * @return in precost plus addcost
405         */
406                public int getCost() {
407                        return precost + addcost;
408                }
409
410                @Override
411                public boolean equals(Object obj) {
412                        if (this == obj) return true;
413                        if (obj == null) return false;
414                        if (getClass() != obj.getClass())
415                                return false;
416                        ToVisit other = (ToVisit)obj;
417                        return this.compareTo(other) == 0;
418                }
419
420                /* (non-Javadoc)
421                 * @see java.lang.Object#hashCode()
422                 */
423                @Override
424                public int hashCode() {
425                        final int prime = 31;
426                        int result = 1;
427                        result = prime * result + getCost();
428                        result = prime * result + ((resolverKey == null) ? 0 : resolverKey.hashCode());
429                        return result;
430                }
431
432                /**
433                 * {@inheritDoc Comparable}
434                 */
435                @Override
436                public int compareTo(ToVisit o) {
437                        if (o == null) return 1;
438                        if (getCost() > o.getCost()) return 1;
439                        if (getCost() < o.getCost()) return -1;
440                        return resolverKey.compareTo(o.resolverKey);
441                }
442
443        /**
444         * Return the {@link TermResolverKey}
445         * @return {@link TermResolverKey}
446         */
447                public TermResolverKey getTermResolverKey() {
448                        return resolverKey;
449                }
450
451        /**
452         * Return the Parent {@link TermResolverKey}
453         * @return the Parent {@link TermResolverKey}
454         */
455                public TermResolverKey getParentKey() {
456                        return parentKey;
457                }
458
459                @Override
460                public String toString() {
461                        return getClass().getSimpleName()+"("+getTermResolverKey()+")";
462                }
463        }
464
465        protected static class TermResolverKey implements Comparable<TermResolverKey> {
466                private final List<String> data;
467                private final String [] params;
468
469                // just used for toArray call
470                private static final String[] TERM_SPEC_TYPER = new String[0];
471                private static final String[] STRING_TYPER = new String[0];
472
473                public TermResolverKey(TermResolver resolver) {
474                        this(resolver.getOutput(), resolver.getParameterNames(), resolver.getPrerequisites());
475                }
476
477                private TermResolverKey(String dest, Set<String> paramSet, Set<String> prereqs) {
478                        if (dest == null) throw new IllegalArgumentException("dest parameter must not be null");
479                        data = new ArrayList<String>(1 + ((prereqs == null) ? 0 : prereqs.size())); // size ArrayList perfectly
480                        data.add(dest);
481                        if (!CollectionUtils.isEmpty(paramSet)) {
482                                params = paramSet.toArray(STRING_TYPER);
483                                Arrays.sort(params);
484                        } else {
485                                params = STRING_TYPER; // just assigning a handy zero length String array
486                        }
487                        if (prereqs != null) {
488                                // this is painful, but to be able to compare we need a defined order
489                                String [] prereqsArray = prereqs.toArray(TERM_SPEC_TYPER);
490                                Arrays.sort(prereqsArray);
491                                for (String prereq : prereqsArray) {
492                                        data.add(prereq);
493                                }
494                        }
495                }
496
497                public String getOutput() {
498                        return data.get(0);
499                }
500
501                @Override
502                public int compareTo(TermResolverKey o) {
503                        if (o == null) return 1;
504
505                        Iterator<String> mDataIter = data.iterator();
506                        Iterator<String> oDataIter = o.data.iterator();
507
508                        while (mDataIter.hasNext() && oDataIter.hasNext()) {
509                                int itemCompareResult = mDataIter.next().compareTo(oDataIter.next());
510                                if (itemCompareResult != 0) return itemCompareResult;
511                        }
512
513                        if (mDataIter.hasNext()) return 1;
514                        if (oDataIter.hasNext()) return -1;
515
516                        return 0;
517                }
518
519                /* (non-Javadoc)
520                 * @see java.lang.Object#hashCode()
521                 */
522                @Override
523                public int hashCode() {
524                        return data.hashCode();
525                }
526
527                /* (non-Javadoc)
528                 * @see java.lang.Object#equals(java.lang.Object)
529                 */
530                @Override
531                public boolean equals(Object obj) {
532                        if (this == obj)
533                                return true;
534                        if (obj == null)
535                                return false;
536                        if (getClass() != obj.getClass())
537                                return false;
538                        TermResolverKey other = (TermResolverKey) obj;
539                        return this.compareTo(other) == 0;
540                }
541
542                @Override
543                public String toString() {
544                        StringBuilder sb = new StringBuilder();
545                        sb.append(getClass().getSimpleName());
546                        sb.append("(");
547                        Iterator<String> iter = data.iterator();
548                        sb.append(iter.next().toString());
549                        if (params.length != 0) {
550                                sb.append("+");
551                                ArrayUtils.toString(params);
552                        }
553
554                        if (iter.hasNext()) sb.append(" <- ");
555                        boolean first = true;
556                        while (iter.hasNext()) {
557                                if (first) first = false;
558                                else sb.append(", ");
559                                sb.append(iter.next().toString());
560                        }
561
562
563                        return sb.toString();
564                }
565        }
566
567
568        private static class Visited {
569                private TermResolverKey resolverKey;
570                private List<TermResolverKey> pathTo;
571                private Set<String> remainingPrereqs;
572                private Map<String, TermResolverKey> prereqResolvers;
573                private int cost;
574
575                /**
576                 * @param resolverKey
577                 * @param pathTo
578                 * @param prereqs
579                 * @param cost
580                 */
581                public Visited(TermResolverKey resolverKey, List<TermResolverKey> pathTo, Set<String> prereqs, int cost) {
582                        super();
583                        this.resolverKey = resolverKey;
584                        this.pathTo = pathTo;
585                        this.remainingPrereqs = new HashSet<String>(prereqs);
586                        this.prereqResolvers = new HashMap<String, TermResolverKey>();
587                        this.cost = cost;
588                }
589
590                /**
591                 * @return the path from the goal node down to this resolver
592                 */
593                public List<TermResolverKey> getPathTo() {
594                        return pathTo;
595                }
596
597                public TermResolverKey getResolverKey() {
598                        return resolverKey;
599                }
600
601                public Collection<TermResolverKey> getPrereqResolvers() {
602                        return prereqResolvers.values();
603                }
604
605                /**
606                 * @return true if resolution of all the prerequisites has been planned
607                 */
608                public boolean isFullyPlanned() {
609                        return remainingPrereqs.isEmpty();
610                }
611
612                public int getCost() {
613                        return cost;
614                }
615
616                public void addPlannedPrereq(TermResolverKey termResolverKey) {
617                        remainingPrereqs.remove(termResolverKey.getOutput());
618                        prereqResolvers.put(termResolverKey.getOutput(), termResolverKey);
619                }
620
621                public void addPlannedPrereq(String termName) {
622                        remainingPrereqs.remove(termName);
623                }
624        }
625
626        private static class InvalidResolutionPathException extends Exception {
627                private static final long serialVersionUID = 1L;
628
629                public InvalidResolutionPathException() {
630                }
631        }
632
633}
634
635