BracesEffectTest.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
 ******************************************************************************/

package org.eclipse.rdf4j.queryrender;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import org.eclipse.rdf4j.query.MalformedQueryException;
import org.eclipse.rdf4j.query.QueryLanguage;
import org.eclipse.rdf4j.query.algebra.TupleExpr;
import org.eclipse.rdf4j.query.parser.ParsedQuery;
import org.eclipse.rdf4j.query.parser.QueryParserUtil;
import org.eclipse.rdf4j.queryrender.sparql.TupleExprIRRenderer;
import org.eclipse.rdf4j.queryrender.sparql.TupleExprToIrConverter;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrSelect;
import org.eclipse.rdf4j.queryrender.sparql.ir.util.IrDebug;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
 * Tests to explore how adding extra curly braces around various parts of a query affects the RDF4J TupleExpr and our
 * IR, and which brace placements are semantically neutral (produce identical TupleExpr structures).
 */
public class BracesEffectTest {

	private static final String SPARQL_PREFIX = "PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>\n" +
			"PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>\n" +
			"PREFIX foaf: <http://xmlns.com/foaf/0.1/>\n" +
			"PREFIX ex: <http://ex/>\n" +
			"PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>\n";

	private static TupleExpr parse(String sparql) {
		try {
			ParsedQuery pq = QueryParserUtil.parseQuery(QueryLanguage.SPARQL, sparql, null);
			return pq.getTupleExpr();
		} catch (MalformedQueryException e) {
			throw new MalformedQueryException("Failed to parse SPARQL query\n" + sparql, e);
		}
	}

	private static String algebra(String sparql) {
		return VarNameNormalizer.normalizeVars(parse(sparql).toString());
	}

	private static TupleExprIRRenderer.Config cfg() {
		TupleExprIRRenderer.Config c = new TupleExprIRRenderer.Config();
		c.prefixes.put("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#");
		c.prefixes.put("rdfs", "http://www.w3.org/2000/01/rdf-schema#");
		c.prefixes.put("foaf", "http://xmlns.com/foaf/0.1/");
		c.prefixes.put("ex", "http://ex/");
		c.prefixes.put("xsd", "http://www.w3.org/2001/XMLSchema#");
		return c;
	}

	private static void write(String base, String label, String text) {
		Path dir = Paths.get("target", "surefire-reports");
		try {
			Files.createDirectories(dir);
			Files.writeString(dir.resolve(base + "_" + label + ".txt"), text, StandardCharsets.UTF_8);
		} catch (IOException e) {
			// ignore in tests
		}
	}

	private static void dumpIr(String base, String body) {
		TupleExprIRRenderer r = new TupleExprIRRenderer(cfg());
		TupleExpr te = parse(SPARQL_PREFIX + body);
		IrSelect ir = new TupleExprToIrConverter(r).toIRSelect(te);
		write(base, "IR", IrDebug.dump(ir));
	}

	private static String render(String body) {
		TupleExprIRRenderer r = new TupleExprIRRenderer(cfg());
		TupleExpr te = parse(SPARQL_PREFIX + body);
		return r.render(te, null).trim();
	}

	private static String stripScopeMarkers(String algebraDump) {
		if (algebraDump == null) {
			return null;
		}
		// Remove RDF4J pretty-printer markers indicating explicit variable-scope changes
		return algebraDump.replace(" (new scope)", "");
	}

	private static void assertSemanticRoundTrip(String base, String body) {
		String input = SPARQL_PREFIX + body;
		String aIn = stripScopeMarkers(algebra(input));
		String rendered = render(body);
		String aOut = stripScopeMarkers(algebra(rendered));
		write(base, "Rendered", rendered);
		write(base, "TupleExpr_input", aIn);
		write(base, "TupleExpr_rendered", aOut);
		assertEquals(aIn, aOut, "Renderer must preserve semantics (algebra equal)");
	}

	private static void compareAndDump(String baseName, String q1, String q2) {
		String a1 = algebra(SPARQL_PREFIX + q1);
		String a2 = algebra(SPARQL_PREFIX + q2);
		write(baseName, "TupleExpr_1", a1);
		write(baseName, "TupleExpr_2", a2);
		String verdict = a1.equals(a2) ? "EQUAL" : "DIFFERENT";
		write(baseName, "TupleExpr_verdict", verdict);
		// Also dump IR for both variants to inspect newScope/grouping differences if any
		dumpIr(baseName + "_1", q1);
		dumpIr(baseName + "_2", q2);
		// Additionally, assert renderer round-trip preserves semantics for both variants
		assertSemanticRoundTrip(baseName + "_rt1", q1);
		assertSemanticRoundTrip(baseName + "_rt2", q2);
	}

	@Test
	@DisplayName("Braces around single triple in WHERE")
	void bracesAroundBGP_noEffect() {
		String q1 = "SELECT ?s ?o WHERE { ?s ex:pA ?o . }";
		String q2 = "SELECT ?s ?o WHERE { { ?s ex:pA ?o . } }";
		compareAndDump("Braces_BGP", q1, q2);
	}

	@Test
	@DisplayName("Double braces around single triple")
	void doubleBracesAroundBGP_noEffect() {
		String q1 = "SELECT ?s ?o WHERE { ?s ex:pA ?o . }";
		String q2 = "SELECT ?s ?o WHERE { { { ?s ex:pA ?o . } } }";
		compareAndDump("Braces_BGP_Double", q1, q2);
	}

	@Test
	@DisplayName("Braces inside GRAPH body")
	void bracesInsideGraph_noEffect() {
		String q1 = "SELECT ?s ?o WHERE { GRAPH <http://graphs.example/g0> { ?s ex:pA ?o . } }";
		String q2 = "SELECT ?s ?o WHERE { GRAPH <http://graphs.example/g0> { { ?s ex:pA ?o . } } }";
		compareAndDump("Braces_GRAPH", q1, q2);
	}

	@Test
	@DisplayName("Braces inside SERVICE body")
	void bracesInsideService_noEffect() {
		String q1 = "SELECT ?s ?o WHERE { SERVICE SILENT <http://federation.example/ep> { ?s ex:pA ?o . } }";
		String q2 = "SELECT ?s ?o WHERE { SERVICE SILENT <http://federation.example/ep> { { ?s ex:pA ?o . } } }";
		compareAndDump("Braces_SERVICE", q1, q2);
	}

	@Test
	@DisplayName("Braces inside MINUS body")
	void bracesInsideMinus_noEffect() {
		String q1 = "SELECT ?s ?o WHERE { ?s ex:pA ?o . MINUS { ?o ex:pB ?x . } }";
		String q2 = "SELECT ?s ?o WHERE { ?s ex:pA ?o . MINUS { { ?o ex:pB ?x . } } }";
		compareAndDump("Braces_MINUS", q1, q2);
	}

	@Test
	@DisplayName("Braces around UNION branches")
	void bracesAroundUnionBranches_noEffect() {
		String q1 = "SELECT ?s ?o WHERE { { ?s ex:pA ?o . } UNION { ?o ex:pB ?s . } }";
		String q2 = "SELECT ?s ?o WHERE { { { ?s ex:pA ?o . } } UNION { { ?o ex:pB ?s . } } }";
		compareAndDump("Braces_UNION_Branches", q1, q2);
	}

	@Test
	@DisplayName("Braces inside FILTER EXISTS body")
	void bracesInsideExists_noEffect() {
		String q1 = "SELECT ?s ?o WHERE { ?s ex:pA ?o . FILTER EXISTS { ?o ex:pB ?x . } }";
		String q2 = "SELECT ?s ?o WHERE { ?s ex:pA ?o . FILTER EXISTS { { ?o ex:pB ?x . } } }";
		compareAndDump("Braces_EXISTS", q1, q2);
	}

	@Test
	@DisplayName("FILTER EXISTS with GRAPH + OPTIONAL NPS: brace vs no-brace body")
	void bracesInsideExists_graphOptionalNps_compare() {
		// With extra curly brackets inside FILTER EXISTS
		String q1 = "SELECT ?s ?o WHERE {\n" +
				"  GRAPH <http://graphs.example/g1> {\n" +
				"    ?s ex:pC ?u1 . \n" +
				"    FILTER EXISTS {\n" +
				"      {\n" +
				"        ?s ex:pA ?o . OPTIONAL {\n" +
				"          ?s !<http://example.org/p/I0> ?o .\n" +
				"        }\n" +
				"      }\n" +
				"    }\n" +
				"  }\n" +
				"}";

		// Without those extra curly brackets (same content, no inner grouping)
		String q2 = "SELECT ?s ?o WHERE {\n" +
				"  GRAPH <http://graphs.example/g1> {\n" +
				"    ?s ex:pC ?u1 . \n" +
				"    FILTER EXISTS {\n" +
				"        ?s ex:pA ?o . OPTIONAL {\n" +
				"          ?s !<http://example.org/p/I0> ?o .\n" +
				"        }\n" +
				"    }\n" +
				"  }\n" +
				"}";

		compareAndDump("Braces_EXISTS_GraphOptionalNPS", q1, q2);
	}

	@Test
	@DisplayName("Braces around VALUES group")
	void bracesAroundValues_noEffect() {
		String q1 = "SELECT ?s WHERE { VALUES ?s { ex:s1 ex:s2 } ?s ex:pA ex:o . }";
		String q2 = "SELECT ?s WHERE { { VALUES ?s { ex:s1 ex:s2 } } ?s ex:pA ex:o . }";
		compareAndDump("Braces_VALUES", q1, q2);
	}
}