FlattenSingletonUnionsTransform.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.sparql.ir.util.transform;

import java.util.ArrayList;
import java.util.List;

import org.eclipse.rdf4j.queryrender.sparql.ir.IrBGP;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrNode;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrOptional;
import org.eclipse.rdf4j.queryrender.sparql.ir.IrUnion;

/**
 * Remove UNION nodes that have a single branch, effectively inlining their content. This keeps the IR compact and
 * avoids printing unnecessary braces/UNION keywords.
 *
 * Safety: - Does not flatten inside OPTIONAL bodies to avoid subtle scope/precedence shifts when later transforms
 * reorder filters and optionals. - Preserves explicit UNIONs with new variable scope (not constructed by transforms),
 * even if they degenerate to a single branch, to respect original user structure.
 */
public final class FlattenSingletonUnionsTransform extends BaseTransform {
	private FlattenSingletonUnionsTransform() {
	}

	public static IrBGP apply(IrBGP bgp) {
		if (bgp == null) {
			return null;
		}
		final List<IrNode> out = new ArrayList<>();
		for (IrNode n : bgp.getLines()) {
			// Recurse first (but do not flatten inside OPTIONAL bodies)
			n = n.transformChildren(child -> {
				if (child instanceof IrOptional) {
					return child; // skip
				}
				if (child instanceof IrBGP) {
					return apply((IrBGP) child);
				}
				return child;
			});
			if (n instanceof IrUnion) {
				IrUnion u = (IrUnion) n;
				// Detect unions that originate from property-path alternation: they often carry
				// newScope=true on the UNION node but have branches with newScope=false. In that
				// case, when only one branch remains, we can safely flatten the UNION node as it
				// is not an explicit user-authored UNION.
				boolean branchesAllNonScoped = true;
				for (IrBGP b : u.getBranches()) {
					if (b != null && b.isNewScope()) {
						branchesAllNonScoped = false;
						break;
					}
				}
				// Preserve explicit UNIONs (newScope=true) unless they are clearly path-generated
				// and have collapsed to a single branch.
				if (u.isNewScope() && !(branchesAllNonScoped && u.getBranches().size() == 1)) {
					out.add(u);
					continue;
				}
				if (u.getBranches().size() == 1) {
					IrBGP only = u.getBranches().get(0);
					out.addAll(only.getLines());
					continue;
				}
			}
			out.add(n);
		}
		return BaseTransform.bgpWithLines(bgp, out);
	}
}