ShaclValidator.java

/*******************************************************************************
 * Copyright (c) 2023 Eclipse RDF4J contributors.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 ******************************************************************************/
// Some portions generated by Codex

package org.eclipse.rdf4j.sail.shacl;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.net.URL;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;

import org.eclipse.rdf4j.common.annotation.Experimental;
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Resource;
import org.eclipse.rdf4j.model.vocabulary.RDF;
import org.eclipse.rdf4j.model.vocabulary.RDF4J;
import org.eclipse.rdf4j.model.vocabulary.RSX;
import org.eclipse.rdf4j.model.vocabulary.SESAME;
import org.eclipse.rdf4j.model.vocabulary.SHACL;
import org.eclipse.rdf4j.repository.sail.SailRepository;
import org.eclipse.rdf4j.repository.sail.SailRepositoryConnection;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.eclipse.rdf4j.rio.RDFParseException;
import org.eclipse.rdf4j.rio.Rio;
import org.eclipse.rdf4j.sail.InterruptedSailException;
import org.eclipse.rdf4j.sail.Sail;
import org.eclipse.rdf4j.sail.SailConnection;
import org.eclipse.rdf4j.sail.SailException;
import org.eclipse.rdf4j.sail.memory.MemoryStore;
import org.eclipse.rdf4j.sail.shacl.ast.Cache;
import org.eclipse.rdf4j.sail.shacl.ast.ContextWithShape;
import org.eclipse.rdf4j.sail.shacl.ast.Shape;
import org.eclipse.rdf4j.sail.shacl.config.ShaclSailConfig;
import org.eclipse.rdf4j.sail.shacl.results.ValidationReport;
import org.eclipse.rdf4j.sail.shacl.results.lazy.LazyValidationReport;
import org.eclipse.rdf4j.sail.shacl.results.lazy.ValidationResultIterator;
import org.eclipse.rdf4j.sail.shacl.wrapper.data.ConnectionsGroup;
import org.eclipse.rdf4j.sail.shacl.wrapper.data.RdfsSubClassOfReasoner;
import org.eclipse.rdf4j.sail.shacl.wrapper.data.VerySimpleRdfsBackwardsChainingConnection;
import org.eclipse.rdf4j.sail.shacl.wrapper.shape.CombinedShapeSource;
import org.eclipse.rdf4j.sail.shacl.wrapper.shape.ShapeSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Experimental
public class ShaclValidator {

	private static final Resource[] ALL_CONTEXTS = {};
	private static final Logger logger = LoggerFactory.getLogger(ShaclValidator.class);

	/**
	 * Create a new builder for configuring a SHACL validator.
	 *
	 * @return a new builder instance
	 */
	public static Builder builder() {
		return new Builder();
	}

	/**
	 * Create a builder seeded from an existing {@link ShaclSail}.
	 *
	 * @param shaclSail the configured SHACL sail supplying shapes and settings
	 * @return a builder preconfigured from the supplied sail
	 */
	public static BuilderWithShapes from(ShaclSail shaclSail) {
		return new BuilderWithShapes(Builder.settingsFrom(shaclSail), shaclSail);
	}

	@FunctionalInterface
	private interface SailLoader {
		void load(SailRepositoryConnection connection) throws IOException, RDFParseException;
	}

	private static Sail loadShapes(String description, SailLoader loader) {
		return loadSail("SHACL shapes", description, loader);
	}

	private static Sail loadData(String description, SailLoader loader) {
		return loadSail("data", description, loader);
	}

	private static Sail loadSail(String kind, String description, SailLoader loader) {
		SailRepository repo = new SailRepository(new MemoryStore());
		repo.init();
		try (SailRepositoryConnection connection = repo.getConnection()) {
			connection.begin(IsolationLevels.NONE);
			loader.load(connection);
			connection.commit();
		} catch (IOException | RDFParseException e) {
			throw new SailException("Failed to read " + kind + " from " + description, e);
		} catch (RuntimeException e) {
			if (e instanceof SailException) {
				throw e;
			}
			throw new SailException("Failed to read " + kind + " from " + description, e);
		}
		return repo.getSail();
	}

	private static RDFFormat detectRdfFormat(String description, String... candidates) {
		for (String candidate : candidates) {
			if (candidate == null || candidate.isBlank()) {
				continue;
			}
			var format = Rio.getParserFormatForFileName(candidate);
			if (format.isPresent()) {
				return format.get();
			}
		}
		throw new SailException(
				"Could not determine RDF format for " + description + ". Provide RDFFormat explicitly.");
	}

	public static class Builder extends InternalBuilder<Builder> implements Cloneable {

		private Builder() {
		}

		/**
		 * Create a builder with settings copied from an existing {@link ShaclSail}.
		 *
		 * @param shaclSail the configured SHACL sail
		 * @return a builder initialized from the supplied sail
		 */
		public static Builder settingsFrom(ShaclSail shaclSail) {
			Builder builder = new Builder();
			builder.setShapesGraphs(shaclSail.getShapesGraphs());
			builder.setParallelValidation(shaclSail.isParallelValidation());
			builder.setLogValidationPlans(shaclSail.isLogValidationPlans());
			builder.setLogValidationViolations(shaclSail.isLogValidationViolations());
			builder.setGlobalLogValidationExecution(shaclSail.isGlobalLogValidationExecution());
			builder.setCacheSelectNodes(shaclSail.isCacheSelectNodes());
			builder.setRdfsSubClassReasoning(shaclSail.isRdfsSubClassReasoning());
			builder.setSerializableValidation(shaclSail.isSerializableValidation());
			builder.setPerformanceLogging(shaclSail.isPerformanceLogging());
			builder.setEclipseRdf4jShaclExtensions(shaclSail.isEclipseRdf4jShaclExtensions());
			builder.setDashDataShapes(shaclSail.isDashDataShapes());
			builder.setValidationResultsLimitTotal(shaclSail.getValidationResultsLimitTotal());
			builder.setValidationResultsLimitPerConstraint(shaclSail.getValidationResultsLimitPerConstraint());
			builder.setTransactionalValidationLimit(shaclSail.getTransactionalValidationLimit());

			if (shaclSail.isValidationEnabled()) {
				builder.enableValidation();
			} else {
				builder.disableValidation();
			}
			return builder;
		}

		/**
		 * Use the supplied SHACL shapes sail for validation.
		 *
		 * @param shapes the shapes sail
		 * @return a builder that validates with the supplied shapes
		 */
		public BuilderWithShapes withShapes(Sail shapes) {
			return new BuilderWithShapes(this, shapes);
		}

		/**
		 * Load SHACL shapes from a file using an auto-detected RDF format.
		 */
		public BuilderWithShapes withShapes(File shapesFile, String baseURI) {
			Objects.requireNonNull(shapesFile, "shapesFile");
			RDFFormat format = detectRdfFormat("file " + shapesFile, shapesFile.getName(), baseURI);
			return withShapes(shapesFile, baseURI, format);
		}

		/**
		 * Load SHACL shapes from a file using an auto-detected RDF format.
		 */
		public BuilderWithShapes withShapes(File shapesFile) {
			Objects.requireNonNull(shapesFile, "shapesFile");
			RDFFormat format = detectRdfFormat("file " + shapesFile, shapesFile.getName());
			return withShapes(shapesFile, format);
		}

		/**
		 * Load SHACL shapes from a path using an auto-detected RDF format.
		 */
		public BuilderWithShapes withShapes(Path shapesPath, String baseURI) {
			Objects.requireNonNull(shapesPath, "shapesPath");
			RDFFormat format = detectRdfFormat("path " + shapesPath, shapesPath.getFileName().toString(), baseURI);
			return withShapes(shapesPath, baseURI, format);
		}

		/**
		 * Load SHACL shapes from a path using an auto-detected RDF format.
		 */
		public BuilderWithShapes withShapes(Path shapesPath) {
			Objects.requireNonNull(shapesPath, "shapesPath");
			RDFFormat format = detectRdfFormat("path " + shapesPath, shapesPath.getFileName().toString());
			return withShapes(shapesPath, format);
		}

		/**
		 * Load SHACL shapes from a URL using an auto-detected RDF format.
		 */
		public BuilderWithShapes withShapes(URL shapesUrl, String baseURI) {
			Objects.requireNonNull(shapesUrl, "shapesUrl");
			RDFFormat format = detectRdfFormat("URL " + shapesUrl, shapesUrl.getPath(), baseURI);
			return withShapes(shapesUrl, baseURI, format);
		}

		/**
		 * Load SHACL shapes from a URL using an auto-detected RDF format.
		 */
		public BuilderWithShapes withShapes(URL shapesUrl) {
			Objects.requireNonNull(shapesUrl, "shapesUrl");
			RDFFormat format = detectRdfFormat("URL " + shapesUrl, shapesUrl.getPath());
			return withShapes(shapesUrl, format);
		}

		/**
		 * Load SHACL shapes from an input stream using an auto-detected RDF format. The input stream is not closed by
		 * this method.
		 */
		public BuilderWithShapes withShapes(InputStream shapesInputStream, String baseURI) {
			Objects.requireNonNull(shapesInputStream, "shapesInputStream");
			RDFFormat format = detectRdfFormat("input stream", baseURI);
			return withShapes(shapesInputStream, baseURI, format);
		}

		/**
		 * Load SHACL shapes from RDF content in a string using an auto-detected RDF format.
		 */
		public BuilderWithShapes withShapes(String shapes, String baseURI) {
			Objects.requireNonNull(shapes, "shapes");
			RDFFormat format = detectRdfFormat("string content", baseURI);
			return withShapes(shapes, baseURI, format);
		}

		/**
		 * Load SHACL shapes from a file using the supplied base URI and RDF format.
		 */
		public BuilderWithShapes withShapes(File shapesFile, String baseURI, RDFFormat format) {
			Objects.requireNonNull(shapesFile, "shapesFile");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapes = loadShapes("file " + shapesFile, connection -> connection.add(shapesFile, baseURI, format));
			return withShapes(shapes);
		}

		/**
		 * Load SHACL shapes from a file using the supplied RDF format.
		 */
		public BuilderWithShapes withShapes(File shapesFile, RDFFormat format) {
			Objects.requireNonNull(shapesFile, "shapesFile");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapes = loadShapes("file " + shapesFile, connection -> connection.add(shapesFile, format));
			return withShapes(shapes);
		}

		/**
		 * Load SHACL shapes from a path using the supplied base URI and RDF format.
		 */
		public BuilderWithShapes withShapes(Path shapesPath, String baseURI, RDFFormat format) {
			Objects.requireNonNull(shapesPath, "shapesPath");
			Objects.requireNonNull(format, "rdfFormat");
			return withShapes(shapesPath.toFile(), baseURI, format);
		}

		/**
		 * Load SHACL shapes from a path using the supplied RDF format.
		 */
		public BuilderWithShapes withShapes(Path shapesPath, RDFFormat format) {
			Objects.requireNonNull(shapesPath, "shapesPath");
			Objects.requireNonNull(format, "rdfFormat");
			return withShapes(shapesPath.toFile(), format);
		}

		/**
		 * Load SHACL shapes from a URL using the supplied base URI and RDF format.
		 */
		public BuilderWithShapes withShapes(URL shapesUrl, String baseURI, RDFFormat format) {
			Objects.requireNonNull(shapesUrl, "shapesUrl");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapes = loadShapes("URL " + shapesUrl, connection -> connection.add(shapesUrl, baseURI, format));
			return withShapes(shapes);
		}

		/**
		 * Load SHACL shapes from a URL using the supplied RDF format.
		 */
		public BuilderWithShapes withShapes(URL shapesUrl, RDFFormat format) {
			Objects.requireNonNull(shapesUrl, "shapesUrl");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapes = loadShapes("URL " + shapesUrl, connection -> connection.add(shapesUrl, format));
			return withShapes(shapes);
		}

		/**
		 * Load SHACL shapes from an input stream using the supplied base URI and RDF format. The input stream is not
		 * closed by this method.
		 */
		public BuilderWithShapes withShapes(InputStream shapesInputStream, String baseURI, RDFFormat format) {
			Objects.requireNonNull(shapesInputStream, "shapesInputStream");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapes = loadShapes("input stream", connection -> connection.add(shapesInputStream, baseURI, format));
			return withShapes(shapes);
		}

		/**
		 * Load SHACL shapes from an input stream using the supplied RDF format. The input stream is not closed by this
		 * method.
		 */
		public BuilderWithShapes withShapes(InputStream shapesInputStream, RDFFormat format) {
			Objects.requireNonNull(shapesInputStream, "shapesInputStream");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapes = loadShapes("input stream", connection -> connection.add(shapesInputStream, format));
			return withShapes(shapes);
		}

		/**
		 * Load SHACL shapes from RDF content in a string using the supplied base URI and RDF format.
		 */
		public BuilderWithShapes withShapes(String shapes, String baseURI, RDFFormat format) {
			Objects.requireNonNull(shapes, "shapes");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapesSail = loadShapes("string content",
					connection -> connection.add(new StringReader(shapes), baseURI, format));
			return withShapes(shapesSail);
		}

		/**
		 * Load SHACL shapes from RDF content in a string using the supplied RDF format.
		 */
		public BuilderWithShapes withShapes(String shapes, RDFFormat format) {
			Objects.requireNonNull(shapes, "shapes");
			Objects.requireNonNull(format, "rdfFormat");
			Sail shapesSail = loadShapes("string content",
					connection -> connection.add(new StringReader(shapes), format));
			return withShapes(shapesSail);
		}

		/**
		 * Build a validator that expects shapes to be supplied at validation time.
		 *
		 * @return a validator configured with the current settings
		 */
		public Validator build() {
			return new Validator(this);
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public Builder clone() {
			return (Builder) super.clone();
		}
	}

	static class InternalBuilder<T extends InternalBuilder<T>> implements Cloneable {
		private Resource[] shapeContexts = null;
		private boolean parallelValidation = true;
		private boolean logValidationPlans = false;
		private boolean logValidationViolations = false;
		private boolean validationEnabled = true;
		private boolean cacheSelectNodes = true;
		private boolean globalLogValidationExecution = false;
		private boolean rdfsSubClassReasoning = true;
		private boolean performanceLogging = false;
		private boolean serializableValidation = false;
		private boolean eclipseRdf4jShaclExtensions = true;
		private boolean dashDataShapes = true;
		private long validationResultsLimitTotal = ShaclSailConfig.VALIDATION_RESULTS_LIMIT_TOTAL_DEFAULT;
		private long validationResultsLimitPerConstraint = ShaclSailConfig.VALIDATION_RESULTS_LIMIT_PER_CONSTRAINT_DEFAULT;
		private long transactionalValidationLimit = ShaclSailConfig.TRANSACTIONAL_VALIDATION_LIMIT_DEFAULT;
		private long validationTimeoutMillis = -1;
		private boolean sparqlValidation = !"false"
				.equalsIgnoreCase(System.getProperty("org.eclipse.rdf4j.sail.shacl.sparqlValidation"));

		void setAll(InternalBuilder<?> other) {
			this.shapeContexts = other.shapeContexts == null ? null : other.shapeContexts.clone();
			this.parallelValidation = other.parallelValidation;
			this.logValidationPlans = other.logValidationPlans;
			this.logValidationViolations = other.logValidationViolations;
			this.validationEnabled = other.validationEnabled;
			this.cacheSelectNodes = other.cacheSelectNodes;
			this.globalLogValidationExecution = other.globalLogValidationExecution;
			this.rdfsSubClassReasoning = other.rdfsSubClassReasoning;
			this.performanceLogging = other.performanceLogging;
			this.serializableValidation = other.serializableValidation;
			this.eclipseRdf4jShaclExtensions = other.eclipseRdf4jShaclExtensions;
			this.dashDataShapes = other.dashDataShapes;
			this.validationResultsLimitTotal = other.validationResultsLimitTotal;
			this.validationResultsLimitPerConstraint = other.validationResultsLimitPerConstraint;
			this.transactionalValidationLimit = other.transactionalValidationLimit;
			this.validationTimeoutMillis = other.validationTimeoutMillis;
			this.sparqlValidation = other.sparqlValidation;
		}

		/**
		 * Set the contexts to use when discovering SHACL shapes.
		 *
		 * @param shapeContexts contexts to scan, or {@code null} to scan all contexts
		 * @return this builder instance
		 */
		public T shapeContexts(Resource... shapeContexts) {
			this.shapeContexts = shapeContexts == null ? null : shapeContexts.clone();
			return (T) this;
		}

		/**
		 * Enable or disable global logging of validation execution.
		 *
		 * @param loggingEnabled whether to enable logging
		 * @return this builder instance
		 */
		public T setGlobalLogValidationExecution(boolean loggingEnabled) {
			this.globalLogValidationExecution = loggingEnabled;
			return (T) this;
		}

		/**
		 * Enable or disable logging of validation violations.
		 *
		 * @param logValidationViolations whether to log violations
		 * @return this builder instance
		 */
		public T setLogValidationViolations(boolean logValidationViolations) {
			this.logValidationViolations = logValidationViolations;
			return (T) this;
		}

		/**
		 * Enable or disable parallel validation.
		 *
		 * @param parallelValidation whether to run validation in parallel
		 * @return this builder instance
		 */
		public T setParallelValidation(boolean parallelValidation) {
			this.parallelValidation = parallelValidation;
			return (T) this;
		}

		/**
		 * Enable or disable caching of select nodes during validation.
		 *
		 * @param cacheSelectNodes whether to cache select nodes
		 * @return this builder instance
		 */
		public T setCacheSelectNodes(boolean cacheSelectNodes) {
			this.cacheSelectNodes = cacheSelectNodes;
			return (T) this;
		}

		/**
		 * Enable or disable RDFS subclass reasoning during validation.
		 *
		 * @param rdfsSubClassReasoning whether to enable subclass reasoning
		 * @return this builder instance
		 */
		public T setRdfsSubClassReasoning(boolean rdfsSubClassReasoning) {
			this.rdfsSubClassReasoning = rdfsSubClassReasoning;
			return (T) this;
		}

		/**
		 * Disable SHACL validation entirely.
		 *
		 * @return this builder instance
		 */
		public T disableValidation() {
			this.validationEnabled = false;
			return (T) this;
		}

		/**
		 * Enable SHACL validation.
		 *
		 * @return this builder instance
		 */
		public T enableValidation() {
			this.validationEnabled = true;
			return (T) this;
		}

		/**
		 * Enable or disable logging of validation plans.
		 *
		 * @param logValidationPlans whether to log validation plans
		 * @return this builder instance
		 */
		public T setLogValidationPlans(boolean logValidationPlans) {
			this.logValidationPlans = logValidationPlans;
			return (T) this;
		}

		/**
		 * Enable or disable performance logging during validation.
		 *
		 * @param performanceLogging whether to log performance details
		 * @return this builder instance
		 */
		public T setPerformanceLogging(boolean performanceLogging) {
			this.performanceLogging = performanceLogging;
			return (T) this;
		}

		/**
		 * Enable or disable serializable validation mode.
		 *
		 * @param serializableValidation whether to enable serializable validation
		 * @return this builder instance
		 */
		public T setSerializableValidation(boolean serializableValidation) {
			this.serializableValidation = serializableValidation;
			return (T) this;
		}

		/**
		 * Enable or disable RDF4J SHACL extensions.
		 *
		 * @param eclipseRdf4jShaclExtensions whether to enable the extensions
		 * @return this builder instance
		 */
		public T setEclipseRdf4jShaclExtensions(boolean eclipseRdf4jShaclExtensions) {
			this.eclipseRdf4jShaclExtensions = eclipseRdf4jShaclExtensions;
			return (T) this;
		}

		/**
		 * Enable or disable DASH data shapes support.
		 *
		 * @param dashDataShapes whether to enable DASH data shapes
		 * @return this builder instance
		 */
		public T setDashDataShapes(boolean dashDataShapes) {
			this.dashDataShapes = dashDataShapes;
			return (T) this;
		}

		/**
		 * Set the maximum number of validation results per constraint.
		 *
		 * @param validationResultsLimitPerConstraint limit per constraint, or a negative value to defer to the total
		 *                                            limit
		 * @return this builder instance
		 */
		public T setValidationResultsLimitPerConstraint(long validationResultsLimitPerConstraint) {
			this.validationResultsLimitPerConstraint = validationResultsLimitPerConstraint;
			return (T) this;
		}

		/**
		 * Set the total maximum number of validation results in a report.
		 *
		 * @param validationResultsLimitTotal total limit, or a negative value for no limit
		 * @return this builder instance
		 */
		public T setValidationResultsLimitTotal(long validationResultsLimitTotal) {
			this.validationResultsLimitTotal = validationResultsLimitTotal;
			return (T) this;
		}

		/**
		 * Set the transactional validation limit.
		 *
		 * @param transactionalValidationLimit the transactional validation limit
		 * @return this builder instance
		 */
		public T setTransactionalValidationLimit(long transactionalValidationLimit) {
			this.transactionalValidationLimit = transactionalValidationLimit;
			return (T) this;
		}

		/**
		 * Set the validation timeout in milliseconds.
		 *
		 * @param validationTimeoutMillis timeout in milliseconds, or a negative value to disable the timeout
		 * @return this builder instance
		 */
		public T setValidationTimeoutMillis(long validationTimeoutMillis) {
			this.validationTimeoutMillis = validationTimeoutMillis;
			return (T) this;
		}

		/**
		 * Set the SHACL shapes graphs to use when discovering shapes.
		 *
		 * <p>
		 * Use {@link RDF4J#NIL} or {@link SESAME#NIL} to indicate the default graph.
		 * </p>
		 *
		 * @param shapesGraphs the shapes graphs, or {@code null} to scan all graphs
		 * @return this builder instance
		 */
		public T setShapesGraphs(Set<IRI> shapesGraphs) {
			if (shapesGraphs == null) {
				this.shapeContexts = null;
				return (T) this;
			}
			if (shapesGraphs.isEmpty()) {
				this.shapeContexts = ALL_CONTEXTS;
				return (T) this;
			}
			this.shapeContexts = shapesGraphs.stream()
					.map(g -> {
						if (RDF4J.NIL.equals(g) || SESAME.NIL.equals(g)) {
							return null;
						}
						return g;
					})
					.toArray(Resource[]::new);
			return (T) this;
		}

		private long getEffectiveValidationResultsLimitPerConstraint() {
			if (validationResultsLimitPerConstraint < 0) {
				return validationResultsLimitTotal;
			}
			if (validationResultsLimitTotal >= 0) {
				return Math.min(validationResultsLimitTotal, validationResultsLimitPerConstraint);
			}
			return validationResultsLimitPerConstraint;
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public InternalBuilder<T> clone() {
			try {
				InternalBuilder<T> clone = (InternalBuilder<T>) super.clone();
				clone.shapeContexts = shapeContexts == null ? null : shapeContexts.clone();
				return clone;
			} catch (CloneNotSupportedException e) {
				throw new AssertionError();
			}
		}
	}

	public static class BuilderWithShapes extends InternalBuilder<BuilderWithShapes> implements Cloneable {

		/**
		 * The shapes sail used for validation.
		 */
		public Sail shapes;

		private BuilderWithShapes(Builder builder, Sail shapes) {
			this.shapes = shapes;
			setAll(builder);
		}

		/**
		 * Build a validator that validates data against the configured shapes.
		 *
		 * @return a validator bound to the shapes sail
		 */
		public ValidatorWithShapes build() {
			return new ValidatorWithShapes(this);
		}

		/**
		 * {@inheritDoc}
		 */
		@Override
		public BuilderWithShapes clone() {
			BuilderWithShapes clone = (BuilderWithShapes) super.clone();
			// TODO: copy mutable state here, so the clone can't change the internals of the original
			return clone;
		}
	}

	public static class ValidatorWithShapes {
		private final BuilderWithShapes builderWithShapes;

		/**
		 * Create a validator that uses the supplied builder configuration.
		 *
		 * @param builderWithShapes configured builder with shapes
		 */
		public ValidatorWithShapes(BuilderWithShapes builderWithShapes) {
			this.builderWithShapes = builderWithShapes.clone();
		}

		private ValidationReport validateLoadedData(String description, SailLoader loader) {
			Sail data = loadData(description, loader);
			return validate(data);
		}

		/**
		 * Validate the supplied data sail against the configured shapes.
		 *
		 * @param dataRepo data sail to validate
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo) {
			return validateInternal(dataRepo, builderWithShapes.shapes, builderWithShapes);
		}

		/**
		 * Load data from a file and validate it against the configured shapes.
		 *
		 * @param dataFile data file to parse
		 * @param baseURI  base URI for resolving relative IRIs
		 * @param format   RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile, String baseURI, RDFFormat format) {
			Objects.requireNonNull(dataFile, "dataFile");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("file " + dataFile, connection -> connection.add(dataFile, baseURI, format));
		}

		/**
		 * Load data from a path and validate it against the configured shapes.
		 *
		 * @param dataPath data path to parse
		 * @param baseURI  base URI for resolving relative IRIs
		 * @param format   RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath, String baseURI, RDFFormat format) {
			Objects.requireNonNull(dataPath, "dataPath");
			return validate(dataPath.toFile(), baseURI, format);
		}

		/**
		 * Load data from a URL and validate it against the configured shapes.
		 *
		 * @param dataUrl data URL to parse
		 * @param baseURI base URI for resolving relative IRIs
		 * @param format  RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl, String baseURI, RDFFormat format) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("URL " + dataUrl, connection -> connection.add(dataUrl, baseURI, format));
		}

		/**
		 * Load data from an input stream and validate it against the configured shapes. The input stream is not closed
		 * by this method.
		 *
		 * @param dataInputStream data input stream to parse
		 * @param baseURI         base URI for resolving relative IRIs
		 * @param format          RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(InputStream dataInputStream, String baseURI, RDFFormat format) {
			Objects.requireNonNull(dataInputStream, "dataInputStream");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("input stream", connection -> connection.add(dataInputStream, baseURI, format));
		}

		/**
		 * Load data from RDF content in a string and validate it against the configured shapes.
		 *
		 * @param data    RDF content to parse
		 * @param baseURI base URI for resolving relative IRIs
		 * @param format  RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(String data, String baseURI, RDFFormat format) {
			Objects.requireNonNull(data, "data");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("string content",
					connection -> connection.add(new StringReader(data), baseURI, format));
		}

		/**
		 * Load data from a file using an auto-detected RDF format and validate it against the configured shapes.
		 *
		 * @param dataFile data file to parse
		 * @param baseURI  base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile, String baseURI) {
			Objects.requireNonNull(dataFile, "dataFile");
			RDFFormat format = detectRdfFormat("file " + dataFile, dataFile.getName(), baseURI);
			return validate(dataFile, baseURI, format);
		}

		/**
		 * Load data from a path using an auto-detected RDF format and validate it against the configured shapes.
		 *
		 * @param dataPath data path to parse
		 * @param baseURI  base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath, String baseURI) {
			Objects.requireNonNull(dataPath, "dataPath");
			RDFFormat format = detectRdfFormat("path " + dataPath, dataPath.getFileName().toString(), baseURI);
			return validate(dataPath, baseURI, format);
		}

		/**
		 * Load data from a URL using an auto-detected RDF format and validate it against the configured shapes.
		 *
		 * @param dataUrl data URL to parse
		 * @param baseURI base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl, String baseURI) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			RDFFormat format = detectRdfFormat("URL " + dataUrl, dataUrl.getPath(), baseURI);
			return validate(dataUrl, baseURI, format);
		}

		/**
		 * Load data from an input stream using an auto-detected RDF format and validate it against the configured
		 * shapes. The input stream is not closed by this method.
		 *
		 * @param dataInputStream data input stream to parse
		 * @param baseURI         base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(InputStream dataInputStream, String baseURI) {
			Objects.requireNonNull(dataInputStream, "dataInputStream");
			RDFFormat format = detectRdfFormat("input stream", baseURI);
			return validate(dataInputStream, baseURI, format);
		}

		/**
		 * Load data from RDF content in a string using an auto-detected RDF format and validate it against the
		 * configured shapes.
		 *
		 * @param data    RDF content to parse
		 * @param baseURI base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(String data, String baseURI) {
			Objects.requireNonNull(data, "data");
			RDFFormat format = detectRdfFormat("string content", baseURI);
			return validate(data, baseURI, format);
		}

		/**
		 * Load data from a file using the supplied RDF format and validate it against the configured shapes.
		 *
		 * @param dataFile data file to parse
		 * @param format   RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile, RDFFormat format) {
			Objects.requireNonNull(dataFile, "dataFile");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("file " + dataFile, connection -> connection.add(dataFile, format));
		}

		/**
		 * Load data from a path using the supplied RDF format and validate it against the configured shapes.
		 *
		 * @param dataPath data path to parse
		 * @param format   RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath, RDFFormat format) {
			Objects.requireNonNull(dataPath, "dataPath");
			return validate(dataPath.toFile(), format);
		}

		/**
		 * Load data from a URL using the supplied RDF format and validate it against the configured shapes.
		 *
		 * @param dataUrl data URL to parse
		 * @param format  RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl, RDFFormat format) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("URL " + dataUrl, connection -> connection.add(dataUrl, format));
		}

		/**
		 * Load data from an input stream using the supplied RDF format and validate it against the configured shapes.
		 * The input stream is not closed by this method.
		 *
		 * @param dataInputStream data input stream to parse
		 * @param format          RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(InputStream dataInputStream, RDFFormat format) {
			Objects.requireNonNull(dataInputStream, "dataInputStream");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("input stream", connection -> connection.add(dataInputStream, format));
		}

		/**
		 * Load data from RDF content in a string using the supplied RDF format and validate it against the configured
		 * shapes.
		 *
		 * @param data   RDF content to parse
		 * @param format RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(String data, RDFFormat format) {
			Objects.requireNonNull(data, "data");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("string content", connection -> connection.add(new StringReader(data), format));
		}

		/**
		 * Load data from a file using an auto-detected RDF format and validate it against the configured shapes.
		 *
		 * @param dataFile data file to parse
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile) {
			Objects.requireNonNull(dataFile, "dataFile");
			RDFFormat format = detectRdfFormat("file " + dataFile, dataFile.getName());
			return validate(dataFile, format);
		}

		/**
		 * Load data from a path using an auto-detected RDF format and validate it against the configured shapes.
		 *
		 * @param dataPath data path to parse
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath) {
			Objects.requireNonNull(dataPath, "dataPath");
			RDFFormat format = detectRdfFormat("path " + dataPath, dataPath.getFileName().toString());
			return validate(dataPath, format);
		}

		/**
		 * Load data from a URL using an auto-detected RDF format and validate it against the configured shapes.
		 *
		 * @param dataUrl data URL to parse
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			RDFFormat format = detectRdfFormat("URL " + dataUrl, dataUrl.getPath());
			return validate(dataUrl, format);
		}

	}

	public static class Validator {
		private final Builder builder;

		/**
		 * Create a validator that uses the supplied builder configuration.
		 *
		 * @param builder configured builder
		 */
		public Validator(Builder builder) {
			this.builder = builder.clone();
		}

		private ValidationReport validateLoadedData(String description, SailLoader loader, Sail shapesRepo) {
			Objects.requireNonNull(shapesRepo, "shapesRepo");
			Sail data = loadData(description, loader);
			return validate(data, shapesRepo);
		}

		/**
		 * Validate the supplied data sail against the supplied shapes sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, Sail shapesRepo) {
			return validateInternal(dataRepo, shapesRepo, builder);
		}

		/**
		 * Load data from a file and validate it against the supplied shapes sail.
		 *
		 * @param dataFile   data file to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile, String baseURI, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(dataFile, "dataFile");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("file " + dataFile, connection -> connection.add(dataFile, baseURI, format),
					shapesRepo);
		}

		/**
		 * Load data from a path and validate it against the supplied shapes sail.
		 *
		 * @param dataPath   data path to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath, String baseURI, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(dataPath, "dataPath");
			return validate(dataPath.toFile(), baseURI, format, shapesRepo);
		}

		/**
		 * Load data from a URL and validate it against the supplied shapes sail.
		 *
		 * @param dataUrl    data URL to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl, String baseURI, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("URL " + dataUrl, connection -> connection.add(dataUrl, baseURI, format),
					shapesRepo);
		}

		/**
		 * Load data from an input stream and validate it against the supplied shapes sail. The input stream is not
		 * closed by this method.
		 *
		 * @param dataInputStream data input stream to parse
		 * @param baseURI         base URI for resolving relative IRIs
		 * @param format          RDF format to use
		 * @param shapesRepo      shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(InputStream dataInputStream, String baseURI, RDFFormat format,
				Sail shapesRepo) {
			Objects.requireNonNull(dataInputStream, "dataInputStream");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("input stream", connection -> connection.add(dataInputStream, baseURI, format),
					shapesRepo);
		}

		/**
		 * Load data from RDF content in a string and validate it against the supplied shapes sail.
		 *
		 * @param data       RDF content to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(String data, String baseURI, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(data, "data");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("string content",
					connection -> connection.add(new StringReader(data), baseURI, format), shapesRepo);
		}

		/**
		 * Load data from a file using an auto-detected RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataFile   data file to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile, String baseURI, Sail shapesRepo) {
			Objects.requireNonNull(dataFile, "dataFile");
			RDFFormat format = detectRdfFormat("file " + dataFile, dataFile.getName(), baseURI);
			return validate(dataFile, baseURI, format, shapesRepo);
		}

		/**
		 * Load data from a path using an auto-detected RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataPath   data path to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath, String baseURI, Sail shapesRepo) {
			Objects.requireNonNull(dataPath, "dataPath");
			RDFFormat format = detectRdfFormat("path " + dataPath, dataPath.getFileName().toString(), baseURI);
			return validate(dataPath, baseURI, format, shapesRepo);
		}

		/**
		 * Load data from a URL using an auto-detected RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataUrl    data URL to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl, String baseURI, Sail shapesRepo) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			RDFFormat format = detectRdfFormat("URL " + dataUrl, dataUrl.getPath(), baseURI);
			return validate(dataUrl, baseURI, format, shapesRepo);
		}

		/**
		 * Load data from an input stream using an auto-detected RDF format and validate it against the supplied shapes
		 * sail. The input stream is not closed by this method.
		 *
		 * @param dataInputStream data input stream to parse
		 * @param baseURI         base URI for resolving relative IRIs
		 * @param shapesRepo      shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(InputStream dataInputStream, String baseURI, Sail shapesRepo) {
			Objects.requireNonNull(dataInputStream, "dataInputStream");
			RDFFormat format = detectRdfFormat("input stream", baseURI);
			return validate(dataInputStream, baseURI, format, shapesRepo);
		}

		/**
		 * Load data from RDF content in a string using an auto-detected RDF format and validate it against the supplied
		 * shapes sail.
		 *
		 * @param data       RDF content to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(String data, String baseURI, Sail shapesRepo) {
			Objects.requireNonNull(data, "data");
			RDFFormat format = detectRdfFormat("string content", baseURI);
			return validate(data, baseURI, format, shapesRepo);
		}

		/**
		 * Load data from a file using the supplied RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataFile   data file to parse
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(dataFile, "dataFile");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("file " + dataFile, connection -> connection.add(dataFile, format), shapesRepo);
		}

		/**
		 * Load data from a path using the supplied RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataPath   data path to parse
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(dataPath, "dataPath");
			return validate(dataPath.toFile(), format, shapesRepo);
		}

		/**
		 * Load data from a URL using the supplied RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataUrl    data URL to parse
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("URL " + dataUrl, connection -> connection.add(dataUrl, format), shapesRepo);
		}

		/**
		 * Load data from an input stream using the supplied RDF format and validate it against the supplied shapes
		 * sail. The input stream is not closed by this method.
		 *
		 * @param dataInputStream data input stream to parse
		 * @param format          RDF format to use
		 * @param shapesRepo      shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(InputStream dataInputStream, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(dataInputStream, "dataInputStream");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("input stream", connection -> connection.add(dataInputStream, format),
					shapesRepo);
		}

		/**
		 * Load data from RDF content in a string using the supplied RDF format and validate it against the supplied
		 * shapes sail.
		 *
		 * @param data       RDF content to parse
		 * @param format     RDF format to use
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(String data, RDFFormat format, Sail shapesRepo) {
			Objects.requireNonNull(data, "data");
			Objects.requireNonNull(format, "rdfFormat");
			return validateLoadedData("string content", connection -> connection.add(new StringReader(data), format),
					shapesRepo);
		}

		/**
		 * Load data from a file using an auto-detected RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataFile   data file to parse
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(File dataFile, Sail shapesRepo) {
			Objects.requireNonNull(dataFile, "dataFile");
			RDFFormat format = detectRdfFormat("file " + dataFile, dataFile.getName());
			return validate(dataFile, format, shapesRepo);
		}

		/**
		 * Load data from a path using an auto-detected RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataPath   data path to parse
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(Path dataPath, Sail shapesRepo) {
			Objects.requireNonNull(dataPath, "dataPath");
			RDFFormat format = detectRdfFormat("path " + dataPath, dataPath.getFileName().toString());
			return validate(dataPath, format, shapesRepo);
		}

		/**
		 * Load data from a URL using an auto-detected RDF format and validate it against the supplied shapes sail.
		 *
		 * @param dataUrl    data URL to parse
		 * @param shapesRepo shapes sail
		 * @return validation report
		 */
		public ValidationReport validate(URL dataUrl, Sail shapesRepo) {
			Objects.requireNonNull(dataUrl, "dataUrl");
			RDFFormat format = detectRdfFormat("URL " + dataUrl, dataUrl.getPath());
			return validate(dataUrl, format, shapesRepo);
		}

		/**
		 * Load shapes from a file and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesFile shapes file to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param format     RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, File shapesFile, String baseURI, RDFFormat format) {
			return builder.withShapes(shapesFile, baseURI, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a path and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesPath shapes path to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @param format     RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, Path shapesPath, String baseURI, RDFFormat format) {
			return builder.withShapes(shapesPath, baseURI, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a URL and validate the supplied data sail.
		 *
		 * @param dataRepo  data sail to validate
		 * @param shapesUrl shapes URL to parse
		 * @param baseURI   base URI for resolving relative IRIs
		 * @param format    RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, URL shapesUrl, String baseURI, RDFFormat format) {
			return builder.withShapes(shapesUrl, baseURI, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from an input stream and validate the supplied data sail. The input stream is not closed by this
		 * method.
		 *
		 * @param dataRepo          data sail to validate
		 * @param shapesInputStream shapes input stream to parse
		 * @param baseURI           base URI for resolving relative IRIs
		 * @param format            RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, InputStream shapesInputStream, String baseURI,
				RDFFormat format) {
			return builder.withShapes(shapesInputStream, baseURI, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from RDF content in a string and validate the supplied data sail.
		 *
		 * @param dataRepo data sail to validate
		 * @param shapes   RDF content for shapes
		 * @param baseURI  base URI for resolving relative IRIs
		 * @param format   RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, String shapes, String baseURI, RDFFormat format) {
			return builder.withShapes(shapes, baseURI, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a file using an auto-detected RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesFile shapes file to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, File shapesFile, String baseURI) {
			return builder.withShapes(shapesFile, baseURI).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a path using an auto-detected RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesPath shapes path to parse
		 * @param baseURI    base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, Path shapesPath, String baseURI) {
			return builder.withShapes(shapesPath, baseURI).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a URL using an auto-detected RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo  data sail to validate
		 * @param shapesUrl shapes URL to parse
		 * @param baseURI   base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, URL shapesUrl, String baseURI) {
			return builder.withShapes(shapesUrl, baseURI).build().validate(dataRepo);
		}

		/**
		 * Load shapes from an input stream using an auto-detected RDF format and validate the supplied data sail. The
		 * input stream is not closed by this method.
		 *
		 * @param dataRepo          data sail to validate
		 * @param shapesInputStream shapes input stream to parse
		 * @param baseURI           base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, InputStream shapesInputStream, String baseURI) {
			return builder.withShapes(shapesInputStream, baseURI).build().validate(dataRepo);
		}

		/**
		 * Load shapes from RDF content in a string using an auto-detected RDF format and validate the supplied data
		 * sail.
		 *
		 * @param dataRepo data sail to validate
		 * @param shapes   RDF content for shapes
		 * @param baseURI  base URI for resolving relative IRIs
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, String shapes, String baseURI) {
			return builder.withShapes(shapes, baseURI).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a file using the supplied RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesFile shapes file to parse
		 * @param format     RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, File shapesFile, RDFFormat format) {
			return builder.withShapes(shapesFile, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a path using the supplied RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesPath shapes path to parse
		 * @param format     RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, Path shapesPath, RDFFormat format) {
			return builder.withShapes(shapesPath, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a URL using the supplied RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo  data sail to validate
		 * @param shapesUrl shapes URL to parse
		 * @param format    RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, URL shapesUrl, RDFFormat format) {
			return builder.withShapes(shapesUrl, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from an input stream using the supplied RDF format and validate the supplied data sail. The input
		 * stream is not closed by this method.
		 *
		 * @param dataRepo          data sail to validate
		 * @param shapesInputStream shapes input stream to parse
		 * @param format            RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, InputStream shapesInputStream, RDFFormat format) {
			return builder.withShapes(shapesInputStream, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from RDF content in a string using the supplied RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo data sail to validate
		 * @param shapes   RDF content for shapes
		 * @param format   RDF format to use
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, String shapes, RDFFormat format) {
			return builder.withShapes(shapes, format).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a file using an auto-detected RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesFile shapes file to parse
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, File shapesFile) {
			return builder.withShapes(shapesFile).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a path using an auto-detected RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo   data sail to validate
		 * @param shapesPath shapes path to parse
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, Path shapesPath) {
			return builder.withShapes(shapesPath).build().validate(dataRepo);
		}

		/**
		 * Load shapes from a URL using an auto-detected RDF format and validate the supplied data sail.
		 *
		 * @param dataRepo  data sail to validate
		 * @param shapesUrl shapes URL to parse
		 * @return validation report
		 */
		public ValidationReport validate(Sail dataRepo, URL shapesUrl) {
			return builder.withShapes(shapesUrl).build().validate(dataRepo);
		}

	}

	private static ValidationReport validateInternal(Sail dataRepo, Sail shapesRepo, InternalBuilder<?> settings) {
		Objects.requireNonNull(dataRepo, "dataRepo");
		Objects.requireNonNull(shapesRepo, "shapesRepo");
		Objects.requireNonNull(settings, "settings");

		if (!settings.validationEnabled) {
			return new ValidationReport(true);
		}

		if (settings.validationTimeoutMillis >= 0) {
			return validateInternalWithTimeout(dataRepo, shapesRepo, settings);
		}

		return validateInternalWithoutTimeout(dataRepo, shapesRepo, settings);
	}

	private static ValidationReport validateInternalWithTimeout(Sail dataRepo, Sail shapesRepo,
			InternalBuilder<?> settings) {
		long validationTimeoutMillis = settings.validationTimeoutMillis;

		ExecutorService executorService = Executors.newSingleThreadExecutor(runnable -> {
			Thread thread = new Thread(runnable, "ShaclValidator-timeout");
			thread.setDaemon(true);
			return thread;
		});

		Future<ValidationReport> future = null;

		try {
			future = executorService.submit(() -> {
				ValidationReport report = validateInternalWithoutTimeout(dataRepo, shapesRepo, settings);
				// Fully evaluate the report so the timeout covers the entire validation process.
				report.conforms();
				return report;
			});

			return future.get(validationTimeoutMillis, TimeUnit.MILLISECONDS);
		} catch (TimeoutException e) {
			future.cancel(true);
			throw new SailException(
					"SHACL validation timed out after " + validationTimeoutMillis + " ms", e);
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			throw new InterruptedSailException(e);
		} catch (ExecutionException e) {
			Throwable cause = e.getCause();
			if (cause instanceof RuntimeException) {
				throw (RuntimeException) cause;
			}
			if (cause instanceof Error) {
				throw (Error) cause;
			}
			throw new SailException(cause);
		} finally {
			executorService.shutdownNow();
		}
	}

	private static ValidationReport validateInternalWithoutTimeout(Sail dataRepo, Sail shapesRepo,
			InternalBuilder<?> settings) {
		List<ContextWithShape> shapes = readShapes(shapesRepo, settings);
		if (shapes.isEmpty()) {
			return new ValidationReport(true);
		}

		try (SailConnection dataRepoConnection = dataRepo.getConnection()) {
			dataRepoConnection.begin(IsolationLevels.NONE);
			try {
				RdfsSubClassOfReasoner reasoner;
				SailConnection baseConnection = dataRepoConnection;
				ConnectionsGroup.RdfsSubClassOfReasonerProvider rdfsSubClassOfReasonerProvider = null;

				if (settings.rdfsSubClassReasoning) {
					try (SailConnection shapesConnection = shapesRepo.getConnection()) {
						shapesConnection.begin(IsolationLevels.NONE);
						try {
							reasoner = RdfsSubClassOfReasoner.createReasoner(dataRepoConnection, shapesConnection,
									new ValidationSettings(ALL_CONTEXTS, settings.logValidationPlans, true,
											settings.performanceLogging));
						} finally {
							shapesConnection.commit();
						}
					}

					RdfsSubClassOfReasoner finalReasoner = reasoner;
					rdfsSubClassOfReasonerProvider = () -> finalReasoner;
					baseConnection = new VerySimpleRdfsBackwardsChainingConnection(dataRepoConnection, finalReasoner);
				}

				ShaclSailConnection.Settings transactionSettings = new ShaclSailConnection.Settings(
						settings.cacheSelectNodes,
						settings.validationEnabled,
						settings.parallelValidation,
						IsolationLevels.NONE);

				try (ConnectionsGroup connectionsGroup = new ConnectionsGroup(
						baseConnection,
						null,
						null,
						null,
						new Stats(),
						rdfsSubClassOfReasonerProvider,
						transactionSettings,
						settings.sparqlValidation)) {
					return performValidation(shapes, connectionsGroup, settings);
				}
			} finally {
				dataRepoConnection.commit();
			}
		}
	}

	private static List<ContextWithShape> readShapes(Sail shapesRepo, InternalBuilder<?> settings) {
		Resource[] shapeContexts = settings.shapeContexts == null ? ALL_CONTEXTS : settings.shapeContexts;
		Shape.ParseSettings parseSettings = new Shape.ParseSettings(settings.eclipseRdf4jShaclExtensions,
				settings.dashDataShapes);

		try (SailConnection shapesConnection = shapesRepo.getConnection()) {
			shapesConnection.begin(IsolationLevels.NONE);
			try (ShapeSource rootShapeSource = new CombinedShapeSource(shapesConnection, shapesConnection)) {
				ShapeSource configuredShapeSource = rootShapeSource.withContext(shapeContexts);
				List<ContextWithShape> shapes = Shape.Factory.getShapes(configuredShapeSource, parseSettings);
				if (!shapes.isEmpty()) {
					return shapes;
				}

				boolean hasMappings = shapesConnection.hasStatement(null, SHACL.SHAPES_GRAPH, null, false,
						shapeContexts)
						|| shapesConnection.hasStatement(null, RDF.TYPE, RSX.DataAndShapesGraphLink, false,
								shapeContexts);
				if (hasMappings) {
					return shapes;
				}

				LinkedHashSet<Resource> fallbackShapesGraphs = new LinkedHashSet<>();
				if (settings.shapeContexts == null) {
					fallbackShapesGraphs.add(null);
					try (var contextIds = shapesConnection.getContextIDs()) {
						while (contextIds.hasNext()) {
							fallbackShapesGraphs.add(contextIds.next());
						}
					}
				} else {
					fallbackShapesGraphs.addAll(Arrays.asList(shapeContexts));
				}

				Cache cache = new Cache();
				List<ContextWithShape> parsed = new ArrayList<>();
				for (Resource shapesGraph : fallbackShapesGraphs) {
					parsed.addAll(Shape.Factory.getShapesInContext(rootShapeSource, parseSettings, cache, ALL_CONTEXTS,
							new Resource[] { shapesGraph }));
				}
				return Shape.Factory.getShapes(parsed);
			} finally {
				shapesConnection.commit();
			}
		}
	}

	private static ValidationReport performValidation(List<ContextWithShape> shapes, ConnectionsGroup connectionsGroup,
			InternalBuilder<?> settings) {

		long effectiveValidationResultsLimitPerConstraint = settings.getEffectiveValidationResultsLimitPerConstraint();
		long validationResultsLimitTotal = settings.validationResultsLimitTotal;

		List<ValidationResultIterator> validationResultIterators = shapes
				.stream()
				.map(contextWithShape -> new ShapeValidationContainer(
						contextWithShape.getShape(),
						() -> contextWithShape.getShape()
								.generatePlans(connectionsGroup,
										new ValidationSettings(contextWithShape.getDataGraph(),
												settings.logValidationPlans,
												true, settings.performanceLogging)),
						settings.globalLogValidationExecution,
						settings.logValidationViolations,
						effectiveValidationResultsLimitPerConstraint,
						settings.performanceLogging,
						settings.logValidationPlans,
						logger,
						connectionsGroup)
				)
				.filter(ShapeValidationContainer::hasPlanNode)
				.map(ShapeValidationContainer::performValidation)
				.collect(Collectors.toList());

		if (Thread.currentThread().isInterrupted()) {
			Thread.currentThread().interrupt();
			throw new InterruptedSailException("Thread was interrupted during validation.");
		}

		return new LazyValidationReport(validationResultIterators, validationResultsLimitTotal);
	}

}