All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.helger.schematron.pure.bound.xpath.PSXPathBoundSchema Maven / Gradle / Ivy

There is a newer version: 5.6.5
Show newest version
/**
 * Copyright (C) 2014-2020 Philip Helger (www.helger.com)
 * philip[at]helger[dot]com
 *
 * 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 com.helger.schematron.pure.bound.xpath;

import java.util.Map;
import java.util.function.Function;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactoryConfigurationException;
import javax.xml.xpath.XPathFunctionResolver;
import javax.xml.xpath.XPathVariableResolver;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import com.helger.commons.ValueEnforcer;
import com.helger.commons.collection.impl.CommonsArrayList;
import com.helger.commons.collection.impl.CommonsHashMap;
import com.helger.commons.collection.impl.ICommonsList;
import com.helger.commons.collection.impl.ICommonsMap;
import com.helger.commons.error.SingleError;
import com.helger.commons.error.level.EErrorLevel;
import com.helger.commons.location.ILocation;
import com.helger.commons.location.SimpleLocation;
import com.helger.commons.string.ToStringGenerator;
import com.helger.schematron.pure.binding.IPSQueryBinding;
import com.helger.schematron.pure.binding.SchematronBindException;
import com.helger.schematron.pure.binding.xpath.PSXPathVariables;
import com.helger.schematron.pure.bound.AbstractPSBoundSchema;
import com.helger.schematron.pure.errorhandler.IPSErrorHandler;
import com.helger.schematron.pure.model.IPSElement;
import com.helger.schematron.pure.model.IPSHasMixedContent;
import com.helger.schematron.pure.model.PSAssertReport;
import com.helger.schematron.pure.model.PSDiagnostic;
import com.helger.schematron.pure.model.PSName;
import com.helger.schematron.pure.model.PSPattern;
import com.helger.schematron.pure.model.PSPhase;
import com.helger.schematron.pure.model.PSRule;
import com.helger.schematron.pure.model.PSSchema;
import com.helger.schematron.pure.model.PSValueOf;
import com.helger.schematron.pure.validation.IPSValidationHandler;
import com.helger.schematron.pure.validation.SchematronValidationException;
import com.helger.schematron.saxon.SaxonNamespaceContext;
import com.helger.schematron.xpath.IXPathConfig;
import com.helger.schematron.xpath.XPathConfigBuilder;
import com.helger.schematron.xpath.XPathEvaluationHelper;
import com.helger.xml.namespace.MapBasedNamespaceContext;
import com.helger.xml.xpath.XPathHelper;

import net.sf.saxon.Configuration;
import net.sf.saxon.lib.ErrorReporter;
import net.sf.saxon.s9api.XmlProcessingError;
import net.sf.saxon.xpath.XPathEvaluator;

/**
 * The default XPath binding for the pure Schematron implementation.
 *
 * @author Philip Helger
 */
@NotThreadSafe
public class PSXPathBoundSchema extends AbstractPSBoundSchema
{
  private static final Logger LOGGER = LoggerFactory.getLogger (PSXPathBoundSchema.class);

  private final IXPathConfig m_aXPathConfig;

  // Status vars
  private ICommonsList  m_aBoundPatterns;

  /**
   * Compile an XPath expression string to an {@link XPathExpressionException}
   * object. If expression contains any variables, the
   * {@link XPathVariableResolver} will be used to resolve them within this
   * method!
   *
   * @param aXPathContext
   *        Context to use. May not be null.
   * @param sXPathExpression
   *        The expression to be compiled. May not be null.
   * @return The precompiled {@link XPathExpression}
   * @throws XPathExpressionException
   *         If expression cannot be compiled.
   */
  @Nullable
  private static XPathExpression _compileXPath (@Nonnull final XPath aXPathContext,
                                                @Nonnull final String sXPathExpression) throws XPathExpressionException
  {
    XPathExpression ret = null;
    try
    {
      ret = aXPathContext.compile (sXPathExpression);
    }
    catch (final XPathExpressionException ex)
    {
      // Do something with it
      throw ex;
    }
    return ret;
  }

  @Nullable
  private ICommonsList  _createBoundElements (@Nonnull final IPSHasMixedContent aMixedContent,
                                                                   @Nonnull final XPath aXPathContext,
                                                                   @Nonnull final PSXPathVariables aVariables)
  {
    final ICommonsList  ret = new CommonsArrayList <> ();
    boolean bHasAnyError = false;

    for (final Object aContentElement : aMixedContent.getAllContentElements ())
    {
      if (aContentElement instanceof PSName)
      {
        final PSName aName = (PSName) aContentElement;
        if (aName.hasPath ())
        {
          // Replace all variables
          final String sPath = aVariables.getAppliedReplacement (aName.getPath ());
          try
          {
            final XPathExpression aXpathExpression = _compileXPath (aXPathContext, sPath);
            ret.add (new PSXPathBoundElement (aName, sPath, aXpathExpression));
          }
          catch (final XPathExpressionException ex)
          {
            error (aName, "Failed to compile XPath expression in : '" + sPath + "'", ex.getCause () != null ? ex.getCause () : ex);
            bHasAnyError = true;
          }
        }
        else
        {
          // No XPath required
          ret.add (new PSXPathBoundElement (aName));
        }
      }
      else
        if (aContentElement instanceof PSValueOf)
        {
          final PSValueOf aValueOf = (PSValueOf) aContentElement;

          // Replace variables
          final String sSelect = aVariables.getAppliedReplacement (aValueOf.getSelect ());
          try
          {
            final XPathExpression aXPathExpression = _compileXPath (aXPathContext, sSelect);
            ret.add (new PSXPathBoundElement (aValueOf, sSelect, aXPathExpression));
          }
          catch (final XPathExpressionException ex)
          {
            error (aValueOf, "Failed to compile XPath expression in : '" + sSelect + "'", ex);
            bHasAnyError = true;
          }
        }
        else
        {
          // No XPath compilation necessary
          if (aContentElement instanceof String)
            ret.add (new PSXPathBoundElement ((String) aContentElement));
          else
            ret.add (new PSXPathBoundElement ((IPSElement) aContentElement));
        }
    }

    if (bHasAnyError)
      return null;

    return ret;
  }

  @Nullable
  private ICommonsMap  _createBoundDiagnostics (@Nonnull final XPath aXPathContext,
                                                                                @Nonnull final PSXPathVariables aGlobalVariables)
  {
    final ICommonsMap  ret = new CommonsHashMap <> ();
    boolean bHasAnyError = false;

    final PSSchema aSchema = getOriginalSchema ();
    if (aSchema.hasDiagnostics ())
    {
      // For all contained diagnostic elements
      for (final PSDiagnostic aDiagnostic : aSchema.getDiagnostics ().getAllDiagnostics ())
      {
        final ICommonsList  aBoundElements = _createBoundElements (aDiagnostic, aXPathContext, aGlobalVariables);
        if (aBoundElements == null)
        {
          // error already emitted
          bHasAnyError = true;
        }
        else
        {
          final PSXPathBoundDiagnostic aBoundDiagnostic = new PSXPathBoundDiagnostic (aDiagnostic, aBoundElements);
          if (ret.put (aDiagnostic.getID (), aBoundDiagnostic) != null)
          {
            error (aDiagnostic, "A diagnostic element with ID '" + aDiagnostic.getID () + "' was overwritten!");
            bHasAnyError = true;
          }
        }
      }
    }

    if (bHasAnyError)
      return null;

    return ret;
  }

  /**
   * Pre-compile all patterns incl. their content
   *
   * @param aXPathContext
   *        Global XPath object to use. May not be null.
   * @param aBoundDiagnostics
   *        A map from DiagnosticID to its mapped counterpart. May not be
   *        null.
   * @param aGlobalVariables
   *        The global Schematron-let variables. May not be null.
   * @return null if an XPath error is contained
   */
  @Nullable
  private ICommonsList  _createBoundPatterns (@Nonnull final XPath aXPathContext,
                                                                   @Nonnull final ICommonsMap  aBoundDiagnostics,
                                                                   @Nonnull final PSXPathVariables aGlobalVariables)
  {
    final ICommonsList  ret = new CommonsArrayList <> ();
    boolean bHasAnyError = false;

    // For all relevant patterns
    for (final PSPattern aPattern : getAllRelevantPatterns ())
    {
      // Handle pattern specific variables
      final PSXPathVariables aPatternVariables;
      if (aPattern.hasAnyLet ())
      {
        // The pattern has special variables, so we need to extend the variable
        // map
        aPatternVariables = aGlobalVariables.getClone ();
        for (final Map.Entry  aEntry : aPattern.getAllLetsAsMap ().entrySet ())
          if (aPatternVariables.add (aEntry).isUnchanged ())
            error (aPattern, "Duplicate  with name '" + aEntry.getKey () + "' in ");
      }
      else
      {
        // Use global variables map as-is
        aPatternVariables = aGlobalVariables;
      }

      // For all rules of the current pattern
      final ICommonsList  aBoundRules = new CommonsArrayList <> ();
      for (final PSRule aRule : aPattern.getAllRules ())
      {
        // Handle rule specific variables
        final PSXPathVariables aRuleVariables;
        if (aRule.hasAnyLet ())
        {
          // The rule has special variables, so we need to extend the
          // variable map
          aRuleVariables = aPatternVariables.getClone ();
          for (final Map.Entry  aEntry : aRule.getAllLetsAsMap ().entrySet ())
            if (aRuleVariables.add (aEntry).isUnchanged ())
              error (aRule, "Duplicate  with name '" + aEntry.getKey () + "' in ");
        }
        else
        {
          // Use pattern variables map as-is
          aRuleVariables = aPatternVariables;
        }

        // For all contained assert and reports within the current rule
        final ICommonsList  aBoundAssertReports = new CommonsArrayList <> ();
        for (final PSAssertReport aAssertReport : aRule.getAllAssertReports ())
        {
          final String sTest = aRuleVariables.getAppliedReplacement (aAssertReport.getTest ());
          try
          {
            final XPathExpression aTestExpr = _compileXPath (aXPathContext, sTest);
            final ICommonsList  aBoundElements = _createBoundElements (aAssertReport, aXPathContext, aRuleVariables);
            if (aBoundElements == null)
            {
              // Error already emitted
              bHasAnyError = true;
            }
            else
            {
              final PSXPathBoundAssertReport aBoundAssertReport = new PSXPathBoundAssertReport (aAssertReport,
                                                                                                sTest,
                                                                                                aTestExpr,
                                                                                                aBoundElements,
                                                                                                aBoundDiagnostics);
              aBoundAssertReports.add (aBoundAssertReport);
            }
          }
          catch (final Throwable t)
          {
            error (aAssertReport,
                   "Failed to compile XPath expression in <" +
                                  (aAssertReport.isAssert () ? "assert" : "report") +
                                  ">: '" +
                                  sTest +
                                  "' with the following variables: " +
                                  aRuleVariables.getAll (),
                   t);
            bHasAnyError = true;
          }
        }

        // Evaluate base node set for this rule
        final String sRuleContext = aGlobalVariables.getAppliedReplacement (getValidationContext (aRule.getContext ()));
        PSXPathBoundRule aBoundRule = null;
        try
        {
          final XPathExpression aRuleContext = _compileXPath (aXPathContext, sRuleContext);
          aBoundRule = new PSXPathBoundRule (aRule, sRuleContext, aRuleContext, aBoundAssertReports);
          aBoundRules.add (aBoundRule);
        }
        catch (final XPathExpressionException ex)
        {
          error (aRule,
                 "Failed to compile XPath expression in : '" + sRuleContext + "'",
                 ex.getCause () != null ? ex.getCause () : ex);
          bHasAnyError = true;
        }
      }

      // Create the bound pattern
      final PSXPathBoundPattern aBoundPattern = new PSXPathBoundPattern (aPattern, aBoundRules);
      ret.add (aBoundPattern);
    }

    if (bHasAnyError)
      return null;

    return ret;
  }

  /**
   * Create a new bound schema. All the XPath pre-compilation happens inside
   * this constructor, so that the
   * {@link #validate(Node, String, IPSValidationHandler)} method can be called
   * many times without compiling the XPath statements again and again.
   *
   * @param aQueryBinding
   *        The query binding to be used. May not be null.
   * @param aOrigSchema
   *        The original schema that should be bound. May not be
   *        null.
   * @param sPhase
   *        The selected phase. May be null indicating that the
   *        default phase of the schema should be used (if present) or all
   *        patterns should be evaluated if no default phase is present.
   * @param aCustomErrorListener
   *        A custom error listener to be used. May be null in
   *        which case a
   *        {@link com.helger.schematron.pure.errorhandler.LoggingPSErrorHandler}
   *        is used internally.
   * @param aCustomValidationHandler
   *        The custom PS validation handler. May be null.
   * @param aXPathConfig
   *        The XPath configuration to be used. May be null.
   * @throws SchematronBindException
   *         In case XPath expressions are incorrect and pre-compilation fails
   */
  public PSXPathBoundSchema (@Nonnull final IPSQueryBinding aQueryBinding,
                             @Nonnull final PSSchema aOrigSchema,
                             @Nullable final String sPhase,
                             @Nullable final IPSErrorHandler aCustomErrorListener,
                             @Nullable final IPSValidationHandler aCustomValidationHandler,
                             @Nullable final IXPathConfig aXPathConfig) throws SchematronBindException
  {
    super (aQueryBinding, aOrigSchema, sPhase, aCustomErrorListener, aCustomValidationHandler);

    // Create a default if none is present
    if (aXPathConfig != null)
      m_aXPathConfig = aXPathConfig;
    else
      try
      {
        m_aXPathConfig = new XPathConfigBuilder ().build ();
      }
      catch (final XPathFactoryConfigurationException ex)
      {
        throw new SchematronBindException ("Failed to create XPath configuration", ex);
      }
  }

  @Nonnull
  private XPath _createXPathContext ()
  {
    final MapBasedNamespaceContext aNamespaceContext = getNamespaceContext ();
    final XPath aXPathContext = XPathHelper.createNewXPath (m_aXPathConfig.getXPathFactory (),
                                                            m_aXPathConfig.getXPathVariableResolver (),
                                                            m_aXPathConfig.getXPathFunctionResolver (),
                                                            aNamespaceContext);

    if ("net.sf.saxon.xpath.XPathEvaluator".equals (aXPathContext.getClass ().getName ()))
    {
      // Saxon implementation special handling
      final XPathEvaluator aSaxonXPath = (XPathEvaluator) aXPathContext;

      // Since 9.7.0-4 it must implement NamespaceResolver
      aSaxonXPath.setNamespaceContext (new SaxonNamespaceContext (aNamespaceContext));

      // Wrap the PSErrorHandler to a ErrorListener
      final Function  factory = cfg -> {
        final IPSErrorHandler aErrHdl = getErrorHandler ();
        return (final XmlProcessingError error) -> {
          final ILocation aLocation = error.getLocation () == null ? null
                                                                   : new SimpleLocation (error.getLocation ().getSystemId (),
                                                                                         error.getLocation ().getLineNumber (),
                                                                                         error.getLocation ().getColumnNumber ());
          aErrHdl.handleError (SingleError.builder ()
                                          .setErrorLevel (error.isWarning () ? EErrorLevel.WARN : EErrorLevel.ERROR)
                                          .setErrorID (error.getErrorCode () != null ? error.getErrorCode ().toString () : null)
                                          .setErrorLocation (aLocation)
                                          .setErrorText (error.getMessage ())
                                          .setLinkedException (error.getCause ())
                                          .build ());
        };
      };
      aSaxonXPath.getConfiguration ().setErrorReporterFactory (factory);
    }
    return aXPathContext;
  }

  @Nonnull
  public PSXPathBoundSchema bind () throws SchematronBindException
  {
    if (LOGGER.isDebugEnabled ())
      LOGGER.debug ("Binding pure Schematron");

    if (m_aBoundPatterns != null)
      throw new IllegalStateException ("bind must only be called once!");

    final PSSchema aSchema = getOriginalSchema ();
    final PSPhase aPhase = getPhase ();

    // Get all "global" variables that are defined in the schema
    final PSXPathVariables aGlobalVariables = new PSXPathVariables ();
    if (aSchema.hasAnyLet ())
      for (final Map.Entry  aEntry : aSchema.getAllLetsAsMap ().entrySet ())
        if (aGlobalVariables.add (aEntry).isUnchanged ())
          error (aSchema, "Duplicate  with name '" + aEntry.getKey () + "' in global ");

    if (aPhase != null)
    {
      // Get all variables that are defined in the specified phase
      for (final Map.Entry  aEntry : aPhase.getAllLetsAsMap ().entrySet ())
        if (aGlobalVariables.add (aEntry).isUnchanged ())
          error (aSchema, "Duplicate  with name '" + aEntry.getKey () + "' in  with name '" + getPhaseID () + "'");
    }

    final XPath aXPathContext = _createXPathContext ();

    // Pre-compile all diagnostics first
    final ICommonsMap  aBoundDiagnostics = _createBoundDiagnostics (aXPathContext, aGlobalVariables);
    if (aBoundDiagnostics == null)
      throw new SchematronBindException ("Failed to precompile the diagnostics of the supplied schema. Check the " +
                                         (isDefaultErrorHandler () ? "log output" : "error listener") +
                                         " for XPath errors!");

    // Perform the pre-compilation of all XPath expressions in the patterns,
    // rules, asserts/reports and the content elements
    m_aBoundPatterns = _createBoundPatterns (aXPathContext, aBoundDiagnostics, aGlobalVariables);
    if (m_aBoundPatterns == null)
      throw new SchematronBindException ("Failed to precompile the supplied schema.");
    return this;
  }

  @Nullable
  public XPathVariableResolver getXPathVariableResolver ()
  {
    return m_aXPathConfig.getXPathVariableResolver ();
  }

  @Nullable
  public XPathFunctionResolver getXPathFunctionResolver ()
  {
    return m_aXPathConfig.getXPathFunctionResolver ();
  }

  @Nonnull
  public String getValidationContext (@Nonnull final String sRuleContext)
  {
    // Do we already have an absolute XPath?
    if (sRuleContext.startsWith ("/"))
      return sRuleContext;

    // Create an absolute XPath expression!
    return "//" + sRuleContext;
  }

  public void validate (@Nonnull final Node aNode,
                        @Nullable final String sBaseURI,
                        @Nonnull final IPSValidationHandler aValidationHandler) throws SchematronValidationException
  {
    ValueEnforcer.notNull (aNode, "Node");
    ValueEnforcer.notNull (aValidationHandler, "ValidationHandler");

    if (m_aBoundPatterns == null)
      throw new IllegalStateException ("bind was never called!");

    final PSSchema aSchema = getOriginalSchema ();
    final PSPhase aPhase = getPhase ();

    // Call the "start" callback method
    aValidationHandler.onStart (aSchema, aPhase, sBaseURI);

    // For all bound patterns
    for (final PSXPathBoundPattern aBoundPattern : m_aBoundPatterns)
    {
      final PSPattern aPattern = aBoundPattern.getPattern ();
      aValidationHandler.onPattern (aPattern);

      // For all bound rules
      rules: for (final PSXPathBoundRule aBoundRule : aBoundPattern.getAllBoundRules ())
      {
        final PSRule aRule = aBoundRule.getRule ();

        // Find all nodes matching the rules
        NodeList aRuleContextNodes = null;
        try
        {
          aRuleContextNodes = XPathEvaluationHelper.evaluateAsNodeList (aBoundRule.getBoundRuleContext (), aNode, sBaseURI);
        }
        catch (final XPathExpressionException ex)
        {
          // Handle the cause, because it is usually a wrapper only
          error (aRule,
                 "Failed to evaluate XPath expression to a nodeset: '" + aBoundRule.getRuleContext () + "'",
                 ex.getCause () != null ? ex.getCause () : ex);
          continue rules;
        }

        aValidationHandler.onRuleStart (aRule, aRuleContextNodes);

        // Check each node, if it matches the assert/report
        final int nRuleMatchingNodes = aRuleContextNodes.getLength ();
        for (int nMatchedNode = 0; nMatchedNode < nRuleMatchingNodes; ++nMatchedNode)
        {
          // XSLT does "fired-rule" for each node
          aValidationHandler.onFiredRule (aRule, aBoundRule.getRuleContext (), nMatchedNode, nRuleMatchingNodes);

          // For all contained assert and report elements
          for (final PSXPathBoundAssertReport aBoundAssertReport : aBoundRule.getAllBoundAssertReports ())
          {
            final PSAssertReport aAssertReport = aBoundAssertReport.getAssertReport ();
            final boolean bIsAssert = aAssertReport.isAssert ();
            final XPathExpression aTestExpression = aBoundAssertReport.getBoundTestExpression ();

            final Node aRuleMatchingNode = aRuleContextNodes.item (nMatchedNode);
            try
            {
              final boolean bTestResult = XPathEvaluationHelper.evaluateAsBoolean (aTestExpression, aRuleMatchingNode, sBaseURI);
              if (bIsAssert)
              {
                // It's an assert
                if (!bTestResult)
                {
                  // Assert failed
                  if (aValidationHandler.onFailedAssert (aAssertReport,
                                                         aBoundAssertReport.getTestExpression (),
                                                         aRuleMatchingNode,
                                                         nMatchedNode,
                                                         aBoundAssertReport)
                                        .isBreak ())
                  {
                    return;
                  }
                }
              }
              else
              {
                // It's a report
                if (bTestResult)
                {
                  // Successful report
                  if (aValidationHandler.onSuccessfulReport (aAssertReport,
                                                             aBoundAssertReport.getTestExpression (),
                                                             aRuleMatchingNode,
                                                             nMatchedNode,
                                                             aBoundAssertReport)
                                        .isBreak ())
                  {
                    return;
                  }
                }
              }
            }
            catch (final XPathExpressionException ex)
            {
              error (aRule,
                     "Failed to evaluate XPath expression to a boolean: '" + aBoundAssertReport.getTestExpression () + "'",
                     ex.getCause () != null ? ex.getCause () : ex);
            }
          }

          if (false)
          {
            // The rule matched at least one node. In this case continue with
            // the next pattern
            break rules;
          }
        }
      }
    }

    // Call the "end" callback method
    aValidationHandler.onEnd (aSchema, aPhase);
  }

  @Override
  public String toString ()
  {
    return ToStringGenerator.getDerived (super.toString ()).append ("boundPatterns", m_aBoundPatterns).getToString ();
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy