ShaclValidatorSparqlMessagesTest.java

/*******************************************************************************
 * Copyright (c) 2025 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.results;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.StringReader;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.Resource;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.vocabulary.RDF4J;
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.sail.SailException;
import org.eclipse.rdf4j.sail.memory.MemoryStore;
import org.eclipse.rdf4j.sail.shacl.ShaclSailValidationReportHelper;
import org.eclipse.rdf4j.sail.shacl.ShaclValidator;
import org.junit.jupiter.api.Test;

public class ShaclValidatorSparqlMessagesTest {

	@Test
	public void multipleSparqlConstraintsDifferentMessages() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Missing ex:name.\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?name }\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Invalid ex:age.\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" +
				"      SELECT $this WHERE {\n" +
				"        $this ex:age ?age .\n" +
				"        FILTER ( datatype(?age) != xsd:integer )\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:alice a ex:Person ;\n" +
				"  ex:age \"twenty\"^^xsd:string .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(2, report.getValidationResult().size());

		assertResultMessagesMatchConstraintMessages(report);
	}

	@Test
	public void multipleMessagesPerConstraintAreAllReported() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Name missing\"@en, \"Naam ontbreekt\"@nl ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?name }\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Age must be integer\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" +
				"      SELECT $this WHERE {\n" +
				"        $this ex:age ?age .\n" +
				"        FILTER ( datatype(?age) != xsd:integer )\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:bob a ex:Person ;\n" +
				"  ex:age \"old\"^^xsd:string .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());

		assertResultMessagesMatchConstraintMessages(report);
	}

	@Test
	public void propertyShapeWithMultipleSparqlConstraintsReportsAllMessages() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"      sh:message \"test\" ;\n" +
				"  sh:property [\n" +
				"    sh:path ex:knows ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:message \"ex:knows values must be IRIs.\" ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        SELECT $this ?value WHERE {\n" +
				"          $this ex:knows ?value .\n" +
				"          FILTER ( !isIRI(?value) )\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ] ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:message \"ex:knows values must be ex:Person.\" ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        SELECT $this ?value WHERE {\n" +
				"          $this ex:knows ?value .\n" +
				"          FILTER NOT EXISTS { ?value a ex:Person }\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ]\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person ;\n" +
				"  ex:knows \"Bob\" , ex:charlie .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		System.out.println(report);
		assertFalse(report.conforms());

		assertResultMessagesMatchConstraintMessages(report);
	}

	@Test
	public void personShapePositiveAgeAndNoSelfManageMessagesPerViolation() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"    sh:message \"a\", \"b\" ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Age must be positive.\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this WHERE {\n" +
				"        $this ex:age ?age .\n" +
				"        FILTER ( ?age <= 0 )\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Person must not manage themselves.\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this WHERE {\n" +
				"        $this ex:manage $this .\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:alice a ex:Person ;\n" +
				"  ex:age \"-1\"^^xsd:integer ;\n" +
				"  ex:age \"-2\"^^xsd:integer ;\n" +
				"  ex:manage ex:alice .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		ShaclSailValidationReportHelper.printValidationReport(report, System.out);
		assertFalse(report.conforms());
		assertEquals(3, report.getValidationResult().size());

		assertResultMessagesMatchConstraintMessages(report);
	}

	@Test
	public void messageBindingOverridesConstraintMessages() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Fallback message\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this ?message WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"        BIND(\"Bound message\" AS ?message)\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("Bound message"));
	}

	@Test
	public void messageBindingUsedWhenNoConstraintMessages() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this ?message WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"        BIND(\"Only bound message\"@en AS ?message)\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("Only bound message@en"));
	}

	@Test
	public void messageTemplatesSubstituteSelectVariables() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Invalid age {?value} for {$this}\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" +
				"      SELECT $this ?value WHERE {\n" +
				"        $this ex:age ?value .\n" +
				"        FILTER ( datatype(?value) != xsd:integer )\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:bob a ex:Person ;\n" +
				"  ex:age \"old\"^^xsd:string .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("Invalid age old for http://example.com/ns#bob"));
	}

	@Test
	public void noMessagesProducesNoResultMessage() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		Model model = report.asModel();
		for (ValidationResult result : report.getValidationResult()) {
			Set<String> resultMessages = toMessageSet(
					model.filter(result.getId(), SHACL.RESULT_MESSAGE, null).objects());
			assertTrue(resultMessages.isEmpty());
		}
	}

	@Test
	public void multipleTemplateMessagesAreAllSubstitutedAndReported() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Bad age {?value}\"@en , \"Leeftijd ongeldig {?value}\"@nl ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" +
				"      SELECT $this ?value WHERE {\n" +
				"        $this ex:age ?value .\n" +
				"        FILTER ( datatype(?value) != xsd:integer )\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:bob a ex:Person ;\n" +
				"  ex:age \"old\"^^xsd:string ;\n" +
				"  ex:age \"ancient\"^^xsd:string .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(2, report.getValidationResult().size());

		Model model = report.asModel();
		for (ValidationResult result : report.getValidationResult()) {
			String valueLabel = model.filter(result.getId(), SHACL.VALUE, null)
					.objects()
					.stream()
					.findFirst()
					.map(Value::stringValue)
					.orElseThrow();

			Set<String> expected = Set.of(
					"Bad age " + valueLabel + "@en",
					"Leeftijd ongeldig " + valueLabel + "@nl");

			Set<String> actual = toMessageSet(model.filter(result.getId(), SHACL.RESULT_MESSAGE, null).objects());
			assertEquals(expected, actual);
		}
	}

	@Test
	public void unboundTemplateVariablesRemainUnchanged() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Missing value {?missing} for {$this}\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("Missing value {?missing} for http://example.com/ns#alice"));
	}

	@Test
	public void repeatedTemplatePlaceholdersAreAllReplaced() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Value {?value} repeats {?value}\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" +
				"      SELECT $this ?value WHERE {\n" +
				"        $this ex:age ?value .\n" +
				"        FILTER ( datatype(?value) != xsd:integer )\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:bob a ex:Person ;\n" +
				"  ex:age \"old\"^^xsd:string .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("Value old repeats old"));
	}

	@Test
	public void messageBindingNonLiteralConvertedToStringLiteral() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:message \"Fallback\" ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this ?message WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"        BIND(ex:msg1 AS ?message)\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("http://example.com/ns#msg1"));
	}

	@Test
	public void preboundShapesGraphAvailableInSparqlConstraints() throws Exception {

		String shapesTrig = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix rdf4j: <http://rdf4j.org/schema/rdf4j#> .\n" +
				"\n" +
				"rdf4j:nil sh:shapesGraph ex:shapesGraph .\n" +
				"\n" +
				"ex:shapesGraph {\n" +
				"  ex:PersonShape a sh:NodeShape ;\n" +
				"    sh:targetClass ex:Person ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        SELECT $this ?message WHERE {\n" +
				"          FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"          BIND(CONCAT(\"sg=\", STR($shapesGraph)) AS ?message)\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ] .\n" +
				"}\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTrig), RDFFormat.TRIG);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("sg=http://example.com/ns#shapesGraph"));
	}

	@Test
	public void preboundCurrentShapeAvailableInSparqlConstraints() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:sparql [\n" +
				"    a sh:SPARQLConstraint ;\n" +
				"    sh:select \"\"\"\n" +
				"      PREFIX ex: <http://example.com/ns#>\n" +
				"      SELECT $this ?message WHERE {\n" +
				"        FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"        BIND(CONCAT(\"cs=\", STR($currentShape)) AS ?message)\n" +
				"      }\n" +
				"    \"\"\" ;\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("cs=http://example.com/ns#PersonShape"));
	}

	@Test
	public void messageTemplatesSubstitutePreboundVariablesForNodeShapes() throws Exception {

		String shapesTrig = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix rdf4j: <http://rdf4j.org/schema/rdf4j#> .\n" +
				"\n" +
				"rdf4j:nil sh:shapesGraph ex:shapesGraph .\n" +
				"\n" +
				"ex:shapesGraph {\n" +
				"  ex:PersonShape a sh:NodeShape ;\n" +
				"    sh:targetClass ex:Person ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:message \"Graph {?shapesGraph} shape {?currentShape} focus {$this}\" ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        SELECT $this WHERE {\n" +
				"          FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ] .\n" +
				"}\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTrig), RDFFormat.TRIG);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report,
				Set.of("Graph http://example.com/ns#shapesGraph shape http://example.com/ns#PersonShape focus http://example.com/ns#alice"));
	}

	@Test
	public void messageTemplatesSubstitutePreboundVariablesForPropertyShapes() throws Exception {

		String shapesTrig = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"@prefix rdf4j: <http://rdf4j.org/schema/rdf4j#> .\n" +
				"\n" +
				"rdf4j:nil sh:shapesGraph ex:shapesGraph .\n" +
				"\n" +
				"ex:shapesGraph {\n" +
				"  ex:PersonShape a sh:NodeShape ;\n" +
				"    sh:targetClass ex:Person ;\n" +
				"    sh:property [\n" +
				"      sh:path ex:age ;\n" +
				"      sh:sparql [\n" +
				"        a sh:SPARQLConstraint ;\n" +
				"        sh:message \"Graph {?shapesGraph} shape {?currentShape} focus {$this} value {?value}\" ;\n" +
				"        sh:select \"\"\"\n" +
				"          PREFIX ex: <http://example.com/ns#>\n" +
				"          PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" +
				"          SELECT $this ?value WHERE {\n" +
				"            $this $PATH ?value .\n" +
				"            FILTER ( ?value <= 0 )\n" +
				"          }\n" +
				"        \"\"\" ;\n" +
				"      ]\n" +
				"    ] .\n" +
				"}\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:alice a ex:Person ;\n" +
				"  ex:age \"-1\"^^xsd:integer .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTrig), RDFFormat.TRIG);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		Model model = report.asModel();
		ValidationResult result = report.getValidationResult().iterator().next();
		Set<String> messages = toMessageSet(model.filter(result.getId(), SHACL.RESULT_MESSAGE, null).objects());
		assertEquals(1, messages.size());
		String message = messages.iterator().next();
		assertTrue(message.startsWith("Graph http://example.com/ns#shapesGraph shape "));
		assertTrue(message.contains(" focus http://example.com/ns#alice value -1"));
		assertFalse(message.contains("{?currentShape}"));
	}

	@Test
	public void messageTemplatesSubstitutePathPlaceholderForPropertyShapes() throws Exception {

		String shapesTrig = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:property [\n" +
				"    sh:path ex:age ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:message \"Bad path {$PATH} and {?PATH}\" ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n" +
				"        SELECT $this ?value WHERE {\n" +
				"          $this $PATH ?value .\n" +
				"          FILTER ( ?value <= 0 )\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ]\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n" +
				"\n" +
				"ex:alice a ex:Person ;\n" +
				"  ex:age \"-1\"^^xsd:integer .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTrig), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report,
				Set.of("Bad path <http://example.com/ns#age> and <http://example.com/ns#age>"));
	}

	@Test
	public void pathPlaceholderWorksForInversePaths() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:property [\n" +
				"    sh:path [ sh:inversePath ex:knows ] ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:message \"Inverse knows values must be Person.\" ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        SELECT $this ?value WHERE {\n" +
				"          $this $PATH ?value .\n" +
				"          FILTER NOT EXISTS { ?value a ex:Person }\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ]\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n" +
				"ex:bob ex:knows ex:alice .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		assertAllResultsHaveMessages(report, Set.of("Inverse knows values must be Person."));
	}

	@Test
	public void illegalPathPlaceholderUseCausesFailure() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:property [\n" +
				"    sh:path ex:knows ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        SELECT $this WHERE {\n" +
				"          BIND($PATH AS ?p)\n" +
				"          FILTER NOT EXISTS { $this ex:name ?n }\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ]\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		SailException ex = assertThrows(SailException.class,
				() -> validate(data, shapes));
		Throwable root = ex;
		while (root.getCause() != null) {
			root = root.getCause();
		}
		assertTrue(root instanceof IllegalStateException);
	}

	@Test
	public void nonIriPathBindingIgnoredForPropertyShapes() throws Exception {

		String shapesTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"@prefix sh: <http://www.w3.org/ns/shacl#> .\n" +
				"\n" +
				"ex:PersonShape a sh:NodeShape ;\n" +
				"  sh:targetClass ex:Person ;\n" +
				"  sh:property [\n" +
				"    sh:path ex:knows ;\n" +
				"    sh:sparql [\n" +
				"      a sh:SPARQLConstraint ;\n" +
				"      sh:message \"knows values must be IRIs\" ;\n" +
				"      sh:select \"\"\"\n" +
				"        PREFIX ex: <http://example.com/ns#>\n" +
				"        SELECT $this (\"notAnIRI\" AS ?path) ?value WHERE {\n" +
				"          $this ex:knows ?value .\n" +
				"          FILTER ( !isIRI(?value) )\n" +
				"        }\n" +
				"      \"\"\" ;\n" +
				"    ]\n" +
				"  ] .\n";

		String dataTtl = "@prefix ex: <http://example.com/ns#> .\n" +
				"\n" +
				"ex:alice a ex:Person ;\n" +
				"  ex:knows \"Bob\" .\n";

		SailRepository shapes = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = shapes.getConnection()) {
			connection.add(new StringReader(shapesTtl), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);
		}

		SailRepository data = new SailRepository(new MemoryStore());
		try (SailRepositoryConnection connection = data.getConnection()) {
			connection.add(new StringReader(dataTtl), RDFFormat.TURTLE);
		}

		ValidationReport report = validate(data, shapes);
		assertFalse(report.conforms());
		assertEquals(1, report.getValidationResult().size());

		Model model = report.asModel();
		for (ValidationResult result : report.getValidationResult()) {
			Set<Value> paths = model.filter(result.getId(), SHACL.RESULT_PATH, null).objects();
			assertEquals(1, paths.size());
			Value path = paths.iterator().next();
			assertTrue(path instanceof IRI, "Expected sh:resultPath to be IRI, got " + path);
			assertEquals("http://example.com/ns#knows", path.stringValue());
		}
	}

	private static ValidationReport validate(SailRepository data, SailRepository shapes) {
		return ShaclValidator.builder()
				.setRdfsSubClassReasoning(false)
				.shapeContexts(RDF4J.SHACL_SHAPE_GRAPH, null)
				.withShapes(shapes.getSail())
				.build()
				.validate(data.getSail());
	}

	private static void assertResultMessagesMatchConstraintMessages(ValidationReport report) {
		Model model = report.asModel();
		for (ValidationResult result : report.getValidationResult()) {
			Resource resultId = result.getId();

			Set<String> resultMessages = toMessageSet(model.filter(resultId, SHACL.RESULT_MESSAGE, null).objects());

			Set<Resource> sourceConstraints = StreamSupport.stream(
					model.filter(resultId, SHACL.SOURCE_CONSTRAINT, null).objects().spliterator(), false)
					.filter(Value::isResource)
					.map(v -> (Resource) v)
					.collect(Collectors.toSet());

			assertEquals(1, sourceConstraints.size(),
					"Expected exactly one sh:sourceConstraint for result " + resultId);

			Resource sourceConstraint = sourceConstraints.iterator().next();
			Set<String> constraintMessages = toMessageSet(
					model.filter(sourceConstraint, SHACL.MESSAGE, null).objects());

			assertEquals(constraintMessages, resultMessages,
					"Result messages did not match constraint messages for result " + resultId);
		}
	}

	private static void assertAllResultsHaveMessages(ValidationReport report, Set<String> expectedMessages) {
		Model model = report.asModel();
		for (ValidationResult result : report.getValidationResult()) {
			Set<String> resultMessages = toMessageSet(
					model.filter(result.getId(), SHACL.RESULT_MESSAGE, null).objects());
			assertEquals(expectedMessages, resultMessages);
		}
	}

	private static Set<String> toMessageSet(Iterable<Value> values) {
		return StreamSupport.stream(values.spliterator(), false)
				.filter(Value::isLiteral)
				.map(v -> (Literal) v)
				.map(l -> l.getLabel() + l.getLanguage().map(lang -> "@" + lang).orElse(""))
				.collect(Collectors.toSet());
	}

}