View Javadoc
1   /**
2    * Copyright (C) 2014-2015 Philip Helger (www.helger.com)
3    * philip[at]helger[dot]com
4    *
5    * Licensed under the Apache License, Version 2.0 (the "License");
6    * you may not use this file except in compliance with the License.
7    * You may obtain a copy of the License at
8    *
9    *         http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package com.helger.schematron.pure.bound.xpath;
18  
19  import java.util.ArrayList;
20  import java.util.HashMap;
21  import java.util.List;
22  import java.util.Map;
23  
24  import javax.annotation.Nonnull;
25  import javax.annotation.Nullable;
26  import javax.annotation.concurrent.Immutable;
27  import javax.xml.xpath.XPath;
28  import javax.xml.xpath.XPathConstants;
29  import javax.xml.xpath.XPathExpression;
30  import javax.xml.xpath.XPathExpressionException;
31  import javax.xml.xpath.XPathFactory;
32  import javax.xml.xpath.XPathFunctionResolver;
33  import javax.xml.xpath.XPathVariableResolver;
34  
35  import org.oclc.purl.dsdl.svrl.SchematronOutputType;
36  import org.w3c.dom.Node;
37  import org.w3c.dom.NodeList;
38  
39  import com.helger.commons.ValueEnforcer;
40  import com.helger.commons.lang.ClassLoaderHelper;
41  import com.helger.commons.string.ToStringGenerator;
42  import com.helger.commons.xml.xpath.XPathHelper;
43  import com.helger.schematron.pure.binding.IPSQueryBinding;
44  import com.helger.schematron.pure.binding.SchematronBindException;
45  import com.helger.schematron.pure.binding.xpath.IPSXPathVariables;
46  import com.helger.schematron.pure.binding.xpath.PSXPathVariables;
47  import com.helger.schematron.pure.bound.AbstractPSBoundSchema;
48  import com.helger.schematron.pure.errorhandler.IPSErrorHandler;
49  import com.helger.schematron.pure.model.IPSElement;
50  import com.helger.schematron.pure.model.IPSHasMixedContent;
51  import com.helger.schematron.pure.model.PSAssertReport;
52  import com.helger.schematron.pure.model.PSDiagnostic;
53  import com.helger.schematron.pure.model.PSName;
54  import com.helger.schematron.pure.model.PSPattern;
55  import com.helger.schematron.pure.model.PSPhase;
56  import com.helger.schematron.pure.model.PSRule;
57  import com.helger.schematron.pure.model.PSSchema;
58  import com.helger.schematron.pure.model.PSValueOf;
59  import com.helger.schematron.pure.validation.IPSValidationHandler;
60  import com.helger.schematron.pure.validation.SchematronValidationException;
61  import com.helger.schematron.pure.validation.xpath.PSXPathValidationHandlerSVRL;
62  import com.helger.schematron.xslt.util.PSErrorListener;
63  
64  import net.sf.saxon.lib.FeatureKeys;
65  import net.sf.saxon.xpath.XPathEvaluator;
66  
67  /**
68   * The default XPath binding for the pure Schematron implementation.
69   *
70   * @author Philip Helger
71   */
72  @Immutable
73  public class PSXPathBoundSchema extends AbstractPSBoundSchema
74  {
75    private final XPathVariableResolver m_aXPathVariableResolver;
76    private final XPathFunctionResolver m_aXPathFunctionResolver;
77    private final XPathFactory m_aXPathFactory;
78    private List <PSXPathBoundPattern> m_aBoundPatterns;
79  
80    /**
81     * Compile an XPath expression string to an {@link XPathExpressionException}
82     * object. If expression contains any variables, the
83     * {@link XPathVariableResolver} will be used to resolve them within this
84     * method!
85     *
86     * @param aXPathContext
87     *        Context to use. May not be <code>null</code>.
88     * @param sXPathExpression
89     *        The expression to be compiled. May not be <code>null</code>.
90     * @return The precompiled {@link XPathExpression}
91     * @throws XPathExpressionException
92     *         If expression cannot be compiled.
93     */
94    @Nullable
95    private static XPathExpression _compileXPath (@Nonnull final XPath aXPathContext,
96                                                  @Nonnull final String sXPathExpression) throws XPathExpressionException
97    {
98      XPathExpression ret = null;
99      try
100     {
101       ret = aXPathContext.compile (sXPathExpression);
102     }
103     catch (final XPathExpressionException ex)
104     {
105       // Do something with it
106       throw ex;
107     }
108     return ret;
109   }
110 
111   @Nullable
112   private List <PSXPathBoundElement> _createBoundElements (@Nonnull final IPSHasMixedContent aMixedContent,
113                                                            @Nonnull final XPath aXPathContext,
114                                                            @Nonnull final IPSXPathVariables aVariables)
115   {
116     final List <PSXPathBoundElement> ret = new ArrayList <PSXPathBoundElement> ();
117     boolean bHasAnyError = false;
118 
119     for (final Object aContentElement : aMixedContent.getAllContentElements ())
120     {
121       if (aContentElement instanceof PSName)
122       {
123         final PSName aName = (PSName) aContentElement;
124         if (aName.hasPath ())
125         {
126           // Replace all variables
127           final String sPath = aVariables.getAppliedReplacement (aName.getPath ());
128           try
129           {
130             final XPathExpression aXpathExpression = _compileXPath (aXPathContext, sPath);
131             ret.add (new PSXPathBoundElement (aName, sPath, aXpathExpression));
132           }
133           catch (final XPathExpressionException ex)
134           {
135             error (aName, "Failed to compile XPath expression in <name>: '" + sPath + "'", ex);
136             bHasAnyError = true;
137           }
138         }
139         else
140         {
141           // No XPath required
142           ret.add (new PSXPathBoundElement (aName));
143         }
144       }
145       else
146         if (aContentElement instanceof PSValueOf)
147         {
148           final PSValueOf aValueOf = (PSValueOf) aContentElement;
149 
150           // Replace variables
151           final String sSelect = aVariables.getAppliedReplacement (aValueOf.getSelect ());
152           try
153           {
154             final XPathExpression aXPathExpression = _compileXPath (aXPathContext, sSelect);
155             ret.add (new PSXPathBoundElement (aValueOf, sSelect, aXPathExpression));
156           }
157           catch (final XPathExpressionException ex)
158           {
159             error (aValueOf, "Failed to compile XPath expression in <value-of>: '" + sSelect + "'", ex);
160             bHasAnyError = true;
161           }
162         }
163         else
164         {
165           // No XPath compilation necessary
166           if (aContentElement instanceof String)
167             ret.add (new PSXPathBoundElement ((String) aContentElement));
168           else
169             ret.add (new PSXPathBoundElement ((IPSElement) aContentElement));
170         }
171     }
172 
173     if (bHasAnyError)
174       return null;
175 
176     return ret;
177   }
178 
179   @Nullable
180   private Map <String, PSXPathBoundDiagnostic> _createBoundDiagnostics (@Nonnull final XPath aXPathContext,
181                                                                         @Nonnull final IPSXPathVariables aGlobalVariables)
182   {
183     final Map <String, PSXPathBoundDiagnostic> ret = new HashMap <String, PSXPathBoundDiagnostic> ();
184     boolean bHasAnyError = false;
185 
186     final PSSchema aSchema = getOriginalSchema ();
187     if (aSchema.hasDiagnostics ())
188     {
189       // For all contained diagnostic elements
190       for (final PSDiagnostic aDiagnostic : aSchema.getDiagnostics ().getAllDiagnostics ())
191       {
192         final List <PSXPathBoundElement> aBoundElements = _createBoundElements (aDiagnostic,
193                                                                                 aXPathContext,
194                                                                                 aGlobalVariables);
195         if (aBoundElements == null)
196         {
197           // error already emitted
198           bHasAnyError = true;
199         }
200         else
201         {
202           final PSXPathBoundDiagnostic aBoundDiagnostic = new PSXPathBoundDiagnostic (aDiagnostic, aBoundElements);
203           if (ret.put (aDiagnostic.getID (), aBoundDiagnostic) != null)
204           {
205             error (aDiagnostic, "A diagnostic element with ID '" + aDiagnostic.getID () + "' was overwritten!");
206             bHasAnyError = true;
207           }
208         }
209       }
210     }
211 
212     if (bHasAnyError)
213       return null;
214 
215     return ret;
216   }
217 
218   /**
219    * Pre-compile all patterns incl. their content
220    *
221    * @param aXPathContext
222    * @param aXPathContext
223    *        Global XPath object to use. May not be <code>null</code>.
224    * @param aBoundDiagnostics
225    *        A map from DiagnosticID to its mapped counterpart. May not be
226    *        <code>null</code>.
227    * @param aGlobalVariables
228    *        The global Schematron-let variables. May not be <code>null</code>.
229    * @return <code>null</code> if an XPath error is contained
230    */
231   @Nullable
232   private List <PSXPathBoundPattern> _createBoundPatterns (@Nonnull final XPath aXPathContext,
233                                                            @Nonnull final Map <String, PSXPathBoundDiagnostic> aBoundDiagnostics,
234                                                            @Nonnull final IPSXPathVariables aGlobalVariables)
235   {
236     final List <PSXPathBoundPattern> ret = new ArrayList <PSXPathBoundPattern> ();
237     boolean bHasAnyError = false;
238 
239     // For all relevant patterns
240     for (final PSPattern aPattern : getAllRelevantPatterns ())
241     {
242       // Handle pattern specific variables
243       final PSXPathVariables aPatternVariables = aGlobalVariables.getClone ();
244 
245       if (aPattern.hasAnyLet ())
246       {
247         // The pattern has special variables, so we need to extend the variable
248         // map
249         for (final Map.Entry <String, String> aEntry : aPattern.getAllLetsAsMap ().entrySet ())
250           if (aPatternVariables.add (aEntry).isUnchanged ())
251             error (aPattern, "Duplicate <let> with name '" + aEntry.getKey () + "' in <pattern>");
252       }
253 
254       // For all rules of the current pattern
255       final List <PSXPathBoundRule> aBoundRules = new ArrayList <PSXPathBoundRule> ();
256       for (final PSRule aRule : aPattern.getAllRules ())
257       {
258         // Handle rule specific variables
259         final PSXPathVariables aRuleVariables = aPatternVariables.getClone ();
260         if (aRule.hasAnyLet ())
261         {
262           // The rule has special variables, so we need to extend the
263           // variable map
264           for (final Map.Entry <String, String> aEntry : aRule.getAllLetsAsMap ().entrySet ())
265           {
266             if (aRuleVariables.add (aEntry).isUnchanged ())
267             {
268               error (aRule, "Duplicate <let> with name '" + aEntry.getKey () + "' in <rule>");
269             }
270           }
271         }
272 
273         // For all contained assert and reports within the current rule
274         final List <PSXPathBoundAssertReport> aBoundAssertReports = new ArrayList <PSXPathBoundAssertReport> ();
275         for (final PSAssertReport aAssertReport : aRule.getAllAssertReports ())
276         {
277           final String sTest = aRuleVariables.getAppliedReplacement (aAssertReport.getTest ());
278           try
279           {
280             final XPathExpression aTestExpr = _compileXPath (aXPathContext, sTest);
281             final List <PSXPathBoundElement> aBoundElements = _createBoundElements (aAssertReport,
282                                                                                     aXPathContext,
283                                                                                     aRuleVariables);
284             if (aBoundElements == null)
285             {
286               // Error already emitted
287               bHasAnyError = true;
288             }
289             else
290             {
291               final PSXPathBoundAssertReport aBoundAssertReport = new PSXPathBoundAssertReport (aAssertReport,
292                                                                                                 sTest,
293                                                                                                 aTestExpr,
294                                                                                                 aBoundElements,
295                                                                                                 aBoundDiagnostics);
296               aBoundAssertReports.add (aBoundAssertReport);
297             }
298           }
299           catch (final Throwable t)
300           {
301             error (aAssertReport, "Failed to compile XPath expression in <" +
302                                   (aAssertReport.isAssert () ? "assert" : "report") +
303                                   ">: '" +
304                                   sTest +
305                                   "' with the following variables: " +
306                                   aRuleVariables.getAll (), t);
307             bHasAnyError = true;
308           }
309         }
310 
311         // Evaluate base node set for this rule
312         final String sRuleContext = aGlobalVariables.getAppliedReplacement (getValidationContext (aRule.getContext ()));
313         PSXPathBoundRule aBoundRule = null;
314         try
315         {
316           final XPathExpression aRuleContext = _compileXPath (aXPathContext, sRuleContext);
317           aBoundRule = new PSXPathBoundRule (aRule, sRuleContext, aRuleContext, aBoundAssertReports);
318           aBoundRules.add (aBoundRule);
319         }
320         catch (final XPathExpressionException ex)
321         {
322           error (aRule, "Failed to compile XPath expression in <rule>: '" + sRuleContext + "'", ex);
323           bHasAnyError = true;
324         }
325       }
326 
327       // Create the bound pattern
328       final PSXPathBoundPattern aBoundPattern = new PSXPathBoundPattern (aPattern, aBoundRules);
329       ret.add (aBoundPattern);
330     }
331 
332     if (bHasAnyError)
333       return null;
334 
335     return ret;
336   }
337 
338   @Nonnull
339   public static XPathFactory createXPathFactorySaxonFirst () throws SchematronBindException
340   {
341     // The XPath object used to compile the expressions
342     XPathFactory aXPathFactory;
343     try
344     {
345       // First try to use Saxon, using the context class loader
346       aXPathFactory = XPathFactory.newInstance (XPathFactory.DEFAULT_OBJECT_MODEL_URI,
347                                                 "net.sf.saxon.xpath.XPathFactoryImpl",
348                                                 ClassLoaderHelper.getContextClassLoader ());
349     }
350     catch (final Exception ex)
351     {
352       // Seems like Saxon is not in the class path - fall back to default JAXP
353       try
354       {
355         aXPathFactory = XPathFactory.newInstance (XPathFactory.DEFAULT_OBJECT_MODEL_URI);
356       }
357       catch (final Exception ex2)
358       {
359         throw new SchematronBindException ("Failed to create JAXP XPathFactory", ex2);
360       }
361     }
362     return aXPathFactory;
363   }
364 
365   /**
366    * Create a new bound schema. All the XPath pre-compilation happens inside
367    * this constructor, so that the {@link #validate(Node, IPSValidationHandler)}
368    * method can be called many times without compiling the XPath statements
369    * again and again.
370    *
371    * @param aQueryBinding
372    *        The query binding to be used. May not be <code>null</code>.
373    * @param aOrigSchema
374    *        The original schema that should be bound. May not be
375    *        <code>null</code>.
376    * @param sPhase
377    *        The selected phase. May be <code>null</code> indicating that the
378    *        default phase of the schema should be used (if present) or all
379    *        patterns should be evaluated if no default phase is present.
380    * @param aCustomErrorListener
381    *        A custom error listener to be used. May be <code>null</code> in
382    *        which case a
383    *        {@link com.helger.schematron.pure.errorhandler.LoggingPSErrorHandler}
384    *        is used internally.
385    * @param aXPathVariableResolver
386    *        Custom XPath variable resolver. May be <code>null</code>.
387    * @param aXPathFunctionResolver
388    *        Custom XPath function resolver. May be <code>null</code>.
389    * @throws SchematronBindException
390    *         In case XPath expressions are incorrect and pre-compilation fails
391    */
392   public PSXPathBoundSchema (@Nonnull final IPSQueryBinding aQueryBinding,
393                              @Nonnull final PSSchema aOrigSchema,
394                              @Nullable final String sPhase,
395                              @Nullable final IPSErrorHandler aCustomErrorListener,
396                              @Nullable final XPathVariableResolver aXPathVariableResolver,
397                              @Nullable final XPathFunctionResolver aXPathFunctionResolver) throws SchematronBindException
398   {
399     super (aQueryBinding, aOrigSchema, sPhase, aCustomErrorListener);
400     m_aXPathVariableResolver = aXPathVariableResolver;
401     m_aXPathFunctionResolver = aXPathFunctionResolver;
402     m_aXPathFactory = createXPathFactorySaxonFirst ();
403   }
404 
405   @Nonnull
406   private XPath _createXPathContext ()
407   {
408     final XPath aXPathContext = XPathHelper.createNewXPath (m_aXPathFactory,
409                                                             m_aXPathVariableResolver,
410                                                             m_aXPathFunctionResolver,
411                                                             getNamespaceContext ());
412 
413     if (aXPathContext instanceof XPathEvaluator)
414     {
415       // Saxon implementation special handling
416       final XPathEvaluator aSaxonXPath = (XPathEvaluator) aXPathContext;
417       if (false)
418       {
419         // Enable this to debug Saxon function resolving
420         aSaxonXPath.getConfiguration ().setBooleanProperty (FeatureKeys.TRACE_EXTERNAL_FUNCTIONS, true);
421       }
422 
423       // Wrap the PSErrorHandler to a ErrorListener
424       aSaxonXPath.getConfiguration ().setErrorListener (new PSErrorListener (getErrorHandler ()));
425     }
426     return aXPathContext;
427   }
428 
429   @Nonnull
430   public PSXPathBoundSchema bind () throws SchematronBindException
431   {
432     if (m_aBoundPatterns != null)
433       throw new IllegalStateException ("bind must only be called once!");
434 
435     final PSSchema aSchema = getOriginalSchema ();
436     final PSPhase aPhase = getPhase ();
437 
438     // Get all "global" variables that are defined in the schema
439     final PSXPathVariables aGlobalVariables = new PSXPathVariables ();
440     if (aSchema.hasAnyLet ())
441       for (final Map.Entry <String, String> aEntry : aSchema.getAllLetsAsMap ().entrySet ())
442         if (aGlobalVariables.add (aEntry).isUnchanged ())
443           error (aSchema, "Duplicate <let> with name '" + aEntry.getKey () + "' in global <schema>");
444 
445     if (aPhase != null)
446     {
447       // Get all variables that are defined in the specified phase
448       for (final Map.Entry <String, String> aEntry : aPhase.getAllLetsAsMap ().entrySet ())
449         if (aGlobalVariables.add (aEntry).isUnchanged ())
450           error (aSchema, "Duplicate <let> with name '" +
451                           aEntry.getKey () +
452                           "' in <phase> with name '" +
453                           getPhaseID () +
454                           "'");
455     }
456 
457     final XPath aXPathContext = _createXPathContext ();
458 
459     // Pre-compile all diagnostics first
460     final Map <String, PSXPathBoundDiagnostic> aBoundDiagnostics = _createBoundDiagnostics (aXPathContext,
461                                                                                             aGlobalVariables);
462     if (aBoundDiagnostics == null)
463       throw new SchematronBindException ("Failed to precompile the diagnostics of the supplied schema. Check the " +
464                                          (isDefaultErrorHandler () ? "log output" : "error listener") +
465                                          " for XPath errors!");
466 
467     // Perform the pre-compilation of all XPath expressions in the patterns,
468     // rules, asserts/reports and the content elements
469     m_aBoundPatterns = _createBoundPatterns (aXPathContext, aBoundDiagnostics, aGlobalVariables);
470     if (m_aBoundPatterns == null)
471       throw new SchematronBindException ("Failed to precompile the supplied schema.");
472     return this;
473   }
474 
475   @Nullable
476   public XPathVariableResolver getXPathVariableResolver ()
477   {
478     return m_aXPathVariableResolver;
479   }
480 
481   @Nullable
482   public XPathFunctionResolver getXPathFunctionResolver ()
483   {
484     return m_aXPathFunctionResolver;
485   }
486 
487   @Nonnull
488   public String getValidationContext (@Nonnull final String sRuleContext)
489   {
490     // Do we already have an absolute XPath?
491     if (sRuleContext.startsWith ("/"))
492       return sRuleContext;
493 
494     // Create an absolute XPath expression!
495     return "//" + sRuleContext;
496   }
497 
498   public void validate (@Nonnull final Node aNode, @Nonnull final IPSValidationHandler aValidationHandler) throws SchematronValidationException
499   {
500     ValueEnforcer.notNull (aNode, "Node");
501     ValueEnforcer.notNull (aValidationHandler, "ValidationHandler");
502 
503     if (m_aBoundPatterns == null)
504       throw new IllegalStateException ("bind was never called!");
505 
506     final PSSchema aSchema = getOriginalSchema ();
507     final PSPhase aPhase = getPhase ();
508 
509     // Call the "start" callback method
510     aValidationHandler.onStart (aSchema, aPhase);
511 
512     // For all bound patterns
513     for (final PSXPathBoundPattern aBoundPattern : m_aBoundPatterns)
514     {
515       final PSPattern aPattern = aBoundPattern.getPattern ();
516       aValidationHandler.onPattern (aPattern);
517 
518       // For all bound rules
519       rules: for (final PSXPathBoundRule aBoundRule : aBoundPattern.getAllBoundRules ())
520       {
521         final PSRule aRule = aBoundRule.getRule ();
522 
523         // Find all nodes matching the rules
524         NodeList aRuleMatchingNodes = null;
525         try
526         {
527           aRuleMatchingNodes = (NodeList) aBoundRule.getBoundRuleExpression ().evaluate (aNode, XPathConstants.NODESET);
528         }
529         catch (final XPathExpressionException ex)
530         {
531           error (aRule,
532                  "Failed to evaluate XPath expression to a nodeset: '" + aBoundRule.getRuleExpression () + "'",
533                  ex);
534           continue rules;
535         }
536 
537         final int nRuleMatchingNodes = aRuleMatchingNodes.getLength ();
538         if (nRuleMatchingNodes > 0)
539         {
540           // For all contained assert and report elements
541           for (final PSXPathBoundAssertReport aBoundAssertReport : aBoundRule.getAllBoundAssertReports ())
542           {
543             // XSLT does "fired-rule" for each node
544             aValidationHandler.onRule (aRule, aBoundRule.getRuleExpression ());
545 
546             final PSAssertReport aAssertReport = aBoundAssertReport.getAssertReport ();
547             final boolean bIsAssert = aAssertReport.isAssert ();
548             final XPathExpression aTestExpression = aBoundAssertReport.getBoundTestExpression ();
549 
550             // Check each node, if it matches the assert/report
551             for (int i = 0; i < nRuleMatchingNodes; ++i)
552             {
553               final Node aRuleMatchingNode = aRuleMatchingNodes.item (i);
554               try
555               {
556                 final boolean bTestResult = ((Boolean) aTestExpression.evaluate (aRuleMatchingNode,
557                                                                                  XPathConstants.BOOLEAN)).booleanValue ();
558                 if (bIsAssert)
559                 {
560                   // It's an assert
561                   if (!bTestResult)
562                   {
563                     // Assert failed
564                     if (aValidationHandler.onFailedAssert (aAssertReport,
565                                                            aBoundAssertReport.getTestExpression (),
566                                                            aRuleMatchingNode,
567                                                            i,
568                                                            aBoundAssertReport).isBreak ())
569                     {
570                       return;
571                     }
572                   }
573                 }
574                 else
575                 {
576                   // It's a report
577                   if (bTestResult)
578                   {
579                     // Successful report
580                     if (aValidationHandler.onSuccessfulReport (aAssertReport,
581                                                                aBoundAssertReport.getTestExpression (),
582                                                                aRuleMatchingNode,
583                                                                i,
584                                                                aBoundAssertReport).isBreak ())
585                     {
586                       return;
587                     }
588                   }
589                 }
590               }
591               catch (final XPathExpressionException ex)
592               {
593                 error (aRule,
594                        "Failed to evaluate XPath expression to a boolean: '" +
595                            aBoundAssertReport.getTestExpression () +
596                            "'",
597                        ex);
598               }
599             }
600           }
601 
602           if (false)
603           {
604             // The rule matched at least one node. In this case continue with
605             // the next pattern
606             break rules;
607           }
608         }
609       }
610     }
611 
612     // Call the "end" callback method
613     aValidationHandler.onEnd (aSchema, aPhase);
614   }
615 
616   @Nonnull
617   public SchematronOutputType validateComplete (@Nonnull final Node aNode) throws SchematronValidationException
618   {
619     final PSXPathValidationHandlerSVRL aValidationHandler = new PSXPathValidationHandlerSVRL (getErrorHandler ());
620     validate (aNode, aValidationHandler);
621     return aValidationHandler.getSVRL ();
622   }
623 
624   @Override
625   public String toString ()
626   {
627     return ToStringGenerator.getDerived (super.toString ()).append ("boundPatterns", m_aBoundPatterns).toString ();
628   }
629 }