Content of file RepositoryFinderSupport.java.html
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html xmlns="http://www.w3.org/1999/xhtml" lang="en"><head><meta http-equiv="Content-Type" content="text/html;charset=UTF-8"/><link rel="stylesheet" href="../jacoco-resources/report.css" type="text/css"/><link rel="shortcut icon" href="../jacoco-resources/report.gif" type="image/gif"/><title>RepositoryFinderSupport.java</title><link rel="stylesheet" href="../jacoco-resources/prettify.css" type="text/css"/><script type="text/javascript" src="../jacoco-resources/prettify.js"></script></head><body onload="window['PR_TAB_WIDTH']=4;prettyPrint()"><div class="breadcrumb" id="breadcrumb"><span class="info"><a href="../jacoco-sessions.html" class="el_session">Sessions</a></span><a href="../index.html" class="el_report">blueMarine II :: Catalog</a> > <a href="index.source.html" class="el_package">it.tidalwave.bluemarine2.model.impl.catalog.finder</a> > <span class="el_source">RepositoryFinderSupport.java</span></div><h1>RepositoryFinderSupport.java</h1><pre class="source lang-java linenums"><span class="nc" id="L1">/*</span>
* *********************************************************************************************************************
*
* blueMarine II: Semantic Media Centre
* http://tidalwave.it/projects/bluemarine2
*
* Copyright (C) 2015 - 2021 by Tidalwave s.a.s. (http://tidalwave.it)
*
* *********************************************************************************************************************
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* *********************************************************************************************************************
*
* git clone https://bitbucket.org/tidalwave/bluemarine2-src
* git clone https://github.com/tidalwave-it/bluemarine2-src
*
* *********************************************************************************************************************
*/
package it.tidalwave.bluemarine2.model.impl.catalog.finder;
import javax.annotation.Nonnegative;
import javax.annotation.Nonnull;
import javax.inject.Inject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.beans.factory.annotation.Configurable;
import org.springframework.util.StreamUtils;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.query.QueryLanguage;
import org.eclipse.rdf4j.query.TupleQuery;
import org.eclipse.rdf4j.query.TupleQueryResult;
import org.eclipse.rdf4j.repository.Repository;
import org.eclipse.rdf4j.repository.RepositoryConnection;
import it.tidalwave.util.Finder;
import it.tidalwave.util.Id;
import it.tidalwave.util.LoggingUtilities;
import it.tidalwave.util.ReflectionUtils;
import it.tidalwave.util.Task;
import it.tidalwave.util.spi.FinderSupport;
import it.tidalwave.role.ContextManager;
import it.tidalwave.bluemarine2.util.ImmutableTupleQueryResult;
import it.tidalwave.bluemarine2.model.impl.catalog.factory.RepositoryEntityFactory;
import it.tidalwave.bluemarine2.model.spi.CacheManager;
import it.tidalwave.bluemarine2.model.spi.CacheManager.Cache;
import it.tidalwave.bluemarine2.model.spi.SourceAwareFinder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import static java.util.stream.Collectors.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import static it.tidalwave.bluemarine2.util.RdfUtilities.streamOf;
import static it.tidalwave.bluemarine2.model.vocabulary.BMMO.*;
/***********************************************************************************************************************
*
* A base class for creating {@link Finder}s.
*
* @param <ENTITY> the entity the {@code Finder} should find
* @param <FINDER> the subclass
*
* @stereotype Finder
*
* @author Fabrizio Giudici
*
**********************************************************************************************************************/
<span class="fc" id="L88">@Configurable @Slf4j</span>
public class RepositoryFinderSupport<ENTITY, FINDER extends Finder<ENTITY>>
extends FinderSupport<ENTITY, FINDER>
implements SourceAwareFinder<ENTITY, FINDER>
{
private static final String REGEX_BINDING_TAG = "^@([A-Za-z0-9]*)@";
private static final String REGEX_BINDING_TAG_LINE = REGEX_BINDING_TAG + ".*$";
private static final String REGEX_COMMENT = "^ *#.*";
private static final String PREFIXES = "PREFIX foaf: <http://xmlns.com/foaf/0.1/>\n"
+ "PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>\n"
+ "PREFIX rel: <http://purl.org/vocab/relationship/>\n"
+ "PREFIX bmmo: <http://bluemarine.tidalwave.it/2015/04/mo/>\n"
+ "PREFIX mo: <http://purl.org/ontology/mo/>\n"
+ "PREFIX vocab: <http://dbtune.org/musicbrainz/resource/vocab/>\n"
+ "PREFIX xs: <http://www.w3.org/2001/XMLSchema#>\n";
private static final String QUERY_COUNT_HOLDER = "queryCount";
private static final long serialVersionUID = 1896412264314804227L;
<span class="fc" id="L111"> private static final SimpleValueFactory FACTORY = SimpleValueFactory.getInstance();</span>
@Nonnull
protected final transient Repository repository;
@Nonnull
private final Class<ENTITY> entityClass;
@Nonnull
private final String idName;
@Nonnull
private final transient Optional<Id> id;
@Nonnull
private final transient Optional<Value> source;
@Nonnull
private final transient Optional<Value> sourceFallback;
@Inject
private transient ContextManager contextManager;
@Inject
private transient RepositoryEntityFactory entityFactory;
@Inject
private transient CacheManager cacheManager;
// FIXME: move to a stats bean
<span class="fc" id="L141"> private static final AtomicInteger queryCount = new AtomicInteger();</span>
<span class="nc" id="L143"> @Getter @Setter</span>
<span class="fc" id="L144"> private static boolean dumpThreadOnQuery = false;</span>
/*******************************************************************************************************************
*
*
*
******************************************************************************************************************/
<span class="pc bnc" id="L151" title="All 16 branches missed."> @RequiredArgsConstructor(staticName = "withSparql") @EqualsAndHashCode @ToString</span>
protected static class QueryAndParameters
{
<span class="fc" id="L154"> @Getter @Nonnull</span>
private final String sparql;
<span class="fc" id="L157"> @Nonnull</span>
private final List<Object> parameters = new ArrayList<>();
@Nonnull
public QueryAndParameters withParameter (@Nonnull final String name, @Nonnull final Optional<? extends Value> value)
{
<span class="fc" id="L163"> return value.map(v -> withParameter(name, v)).orElse(this);</span>
}
@Nonnull
public QueryAndParameters withParameter (@Nonnull final String name, @Nonnull final Value value)
{
<span class="fc" id="L169"> parameters.addAll(List.of(name, value));</span>
<span class="fc" id="L170"> return this;</span>
}
@Nonnull
public Object[] getParameters()
{
<span class="fc" id="L176"> return parameters.toArray();</span>
}
@Nonnull
private String getCountSparql()
{
<span class="fc" id="L182"> return String.format("SELECT (COUNT(*) AS ?%s)%n {%n%s%n }",</span>
QUERY_COUNT_HOLDER,
<span class="fc" id="L184"> sparql.replaceAll("ORDER BY[\\s\\S]*", ""));</span>
}
}
/*******************************************************************************************************************
*
*
*
******************************************************************************************************************/
protected RepositoryFinderSupport (@Nonnull final Repository repository, @Nonnull final String idName)
<span class="pc bpc" id="L194" title="9 of 18 branches missed."> {</span>
<span class="fc" id="L195"> this.repository = repository;</span>
<span class="fc" id="L196"> this.entityClass = (Class<ENTITY>)ReflectionUtils.getTypeArguments(RepositoryFinderSupport.class, getClass()).get(0);</span>
<span class="fc" id="L197"> this.idName = idName;</span>
<span class="fc" id="L198"> this.id = Optional.empty();</span>
<span class="fc" id="L199"> this.source = Optional.of(O_SOURCE_EMBEDDED); // FIXME: resets</span>
<span class="fc" id="L200"> this.sourceFallback = Optional.empty(); // FIXME: resets</span>
<span class="pc bpc" id="L201" title="2 of 4 branches missed."> }</span>
/*******************************************************************************************************************
*
*
*
******************************************************************************************************************/
private RepositoryFinderSupport (@Nonnull final Repository repository,
@Nonnull final Class<ENTITY> entityClass,
@Nonnull final String idName,
@Nonnull final Optional<Id> id,
@Nonnull final Optional<Value> source,
@Nonnull final Optional<Value> sourceFallback)
<span class="pc bpc" id="L214" title="9 of 18 branches missed."> {</span>
<span class="fc" id="L215"> this.repository = repository;</span>
<span class="fc" id="L216"> this.entityClass = entityClass;</span>
<span class="fc" id="L217"> this.idName = idName;</span>
<span class="fc" id="L218"> this.id = id;</span>
<span class="fc" id="L219"> this.source = source;</span>
<span class="fc" id="L220"> this.sourceFallback = sourceFallback;</span>
<span class="pc bpc" id="L221" title="2 of 4 branches missed."> }</span>
/*******************************************************************************************************************
*
* Clone constructor.
*
******************************************************************************************************************/
public RepositoryFinderSupport (@Nonnull final RepositoryFinderSupport<ENTITY, FINDER> other,
@Nonnull final Object override)
{
<span class="pc bpc" id="L231" title="9 of 18 branches missed."> super(other, override);</span>
<span class="fc" id="L232"> final RepositoryFinderSupport<ENTITY, FINDER> source = getSource(RepositoryFinderSupport.class, other, override);</span>
<span class="fc" id="L233"> this.repository = source.repository;</span>
<span class="fc" id="L234"> this.entityClass = source.entityClass;</span>
<span class="fc" id="L235"> this.idName = source.idName;</span>
<span class="fc" id="L236"> this.id = source.id;</span>
<span class="fc" id="L237"> this.source = source.source;</span>
<span class="fc" id="L238"> this.sourceFallback = source.sourceFallback;</span>
<span class="pc bpc" id="L239" title="2 of 4 branches missed."> }</span>
/*******************************************************************************************************************
*
* {@inheritDoc}
*
******************************************************************************************************************/
@Override @Nonnull
protected final List<? extends ENTITY> computeNeededResults()
{
<span class="fc" id="L249"> return query(QueryAndParameters::getSparql,</span>
<span class="fc" id="L250"> result -> createEntities(repository, entityClass, result),</span>
<span class="fc" id="L251"> result -> String.format("%d entities", result.size()));</span>
}
/*******************************************************************************************************************
*
* {@inheritDoc}
*
******************************************************************************************************************/
@Override @Nonnegative
public int count()
{
<span class="fc" id="L262"> return query(QueryAndParameters::getCountSparql,</span>
<span class="fc" id="L263"> result -> Integer.parseInt(result.next().getValue(QUERY_COUNT_HOLDER).stringValue()),</span>
<span class="fc" id="L264"> result -> String.format("%d", result));</span>
}
/*******************************************************************************************************************
*
* {@inheritDoc}
*
******************************************************************************************************************/
@Override @Nonnull
public FINDER withId (@Nonnull final Id id)
{
<span class="nc" id="L275"> return clonedWith(new RepositoryFinderSupport(repository,</span>
entityClass,
idName,
<span class="nc" id="L278"> Optional.of(id),</span>
source,
sourceFallback));
}
/*******************************************************************************************************************
*
* {@inheritDoc}
*
******************************************************************************************************************/
@Override @Nonnull
public FINDER importedFrom (@Nonnull final Optional<Id> optionalSource)
{
<span class="fc" id="L291"> return optionalSource.map(this::importedFrom).orElse((FINDER)this);</span>
}
/*******************************************************************************************************************
*
* {@inheritDoc}
*
******************************************************************************************************************/
@Override @Nonnull
public FINDER importedFrom (@Nonnull final Id source)
{
<span class="fc" id="L302"> return clonedWith(new RepositoryFinderSupport(repository,</span>
entityClass,
idName,
id,
<span class="fc" id="L306"> Optional.of(FACTORY.createLiteral(source.toString())),</span>
sourceFallback));
}
/*******************************************************************************************************************
*
* {@inheritDoc}
*
******************************************************************************************************************/
@Override @Nonnull
public FINDER withFallback (@Nonnull final Optional<Id> sourceFallback)
{
<span class="fc" id="L318"> return sourceFallback.map(this::withFallback).orElse((FINDER)this);</span>
}
/*******************************************************************************************************************
*
* {@inheritDoc}
*
******************************************************************************************************************/
@Override @Nonnull
public FINDER withFallback (@Nonnull final Id sourceFallback)
{
<span class="fc" id="L329"> return clonedWith(new RepositoryFinderSupport(repository,</span>
entityClass,
idName,
id,
source,
<span class="fc" id="L334"> Optional.of(FACTORY.createLiteral(sourceFallback.toString()))));</span>
}
/*******************************************************************************************************************
*
* Returns the count of queries performed so far.
*
* @return the count of queries
*
******************************************************************************************************************/
@Nonnegative
public static int getQueryCount()
{
<span class="fc" id="L347"> return queryCount.intValue();</span>
}
/*******************************************************************************************************************
*
* Resets the count of queries performed so far.
*
******************************************************************************************************************/
public static void resetQueryCount()
{
<span class="fc" id="L357"> queryCount.set(0);</span>
<span class="fc" id="L358"> }</span>
/*******************************************************************************************************************
*
* Prepares the SPARQL query and its parameters.
*
* @return the SPARQL query and its parameters
*
******************************************************************************************************************/
@Nonnull
protected /* abstract */ QueryAndParameters prepareQuery()
{
<span class="nc" id="L370"> throw new UnsupportedOperationException("Must be implemented by subclasses");</span>
}
/*******************************************************************************************************************
*
* Performs a query, eventually using the cache.
*
* @param sparqlSelector a function that select the SPARQL statement to use
* @param finalizer a function to transform the query raw result into the final result
* @param resultToString a function that provide the logging string for the result
* @return the found entities
*
******************************************************************************************************************/
@Nonnull
private <E> E query (@Nonnull final Function<QueryAndParameters, String> sparqlSelector,
@Nonnull final Function<TupleQueryResult, E> finalizer,
@Nonnull final Function<E, String> resultToString)
{
<span class="fc" id="L388"> log.info("query() - {}", entityClass);</span>
<span class="fc" id="L389"> final long baseTime = System.nanoTime();</span>
<span class="fc" id="L390"> final QueryAndParameters queryAndParameters = prepareQuery()</span>
<span class="fc" id="L391"> .withParameter(idName, id.map(this::iriFor))</span>
<span class="fc" id="L392"> .withParameter("source", source)</span>
<span class="fc bfc" id="L393" title="All 2 branches covered."> .withParameter("fallback", sourceFallback.equals(source) ? Optional.empty() : sourceFallback);</span>
<span class="fc" id="L394"> final Object[] parameters = queryAndParameters.getParameters();</span>
<span class="fc" id="L395"> final String originalSparql = sparqlSelector.apply(queryAndParameters);</span>
<span class="fc" id="L396"> final String sparql = PREFIXES + Stream.of(originalSparql.split("\n"))</span>
<span class="fc" id="L397"> .filter(s -> matchesTag(s, parameters))</span>
<span class="fc" id="L398"> .map(s -> s.replaceAll(REGEX_BINDING_TAG, ""))</span>
<span class="fc" id="L399"> .collect(joining("\n"));</span>
<span class="fc" id="L400"> log(originalSparql, sparql, parameters);</span>
<span class="fc" id="L401"> final E result = query(sparql, finalizer, parameters);</span>
<span class="fc" id="L402"> queryCount.incrementAndGet();</span>
<span class="fc" id="L403"> final long elapsedTime = System.nanoTime() - baseTime;</span>
<span class="fc" id="L404"> log.info(">>>> query returned {} in {} msec", resultToString.apply(result), elapsedTime / 1E6);</span>
<span class="fc" id="L405"> LoggingUtilities.dumpStack(this, dumpThreadOnQuery);</span>
<span class="fc" id="L407"> return result;</span>
}
/*******************************************************************************************************************
*
* Performs a query.
*
* @param sparql the SPARQL of the query
* @param finalizer a function to transform the query raw result into the final result
* @param parameters an optional set of parameters of the query ("name", value, "name", value ,,,)
* @return the found entities
*
******************************************************************************************************************/
@Nonnull
private <R> R query (@Nonnull final String sparql,
@Nonnull final Function<TupleQueryResult, R> finalizer,
@Nonnull final Object ... parameters)
{
<span class="fc" id="L425"> try (final RepositoryConnection connection = repository.getConnection())</span>
{
<span class="fc" id="L427"> final TupleQuery query = connection.prepareTupleQuery(QueryLanguage.SPARQL, sparql);</span>
<span class="fc bfc" id="L429" title="All 2 branches covered."> for (int i = 0; i < parameters.length; i += 2)</span>
{
<span class="fc" id="L431"> query.setBinding((String)parameters[i], (Value)parameters[i + 1]);</span>
}
//
// Don't cache entities because they are injected with DCI roles in function of the context.
// Caching tuples is safe.
<span class="fc" id="L436"> final Cache cache = cacheManager.getCache(RepositoryFinderSupport.class);</span>
<span class="fc" id="L437"> final String key = String.format("%s # %s", compacted(sparql), Arrays.toString(parameters));</span>
<span class="fc" id="L439"> try (final ImmutableTupleQueryResult result = cache.getCachedObject(key,</span>
<span class="fc" id="L440"> () -> new ImmutableTupleQueryResult(query.evaluate())))</span>
{
// ImmutableTupleQueryResult is not thread safe, so clone the cached instance
<span class="fc" id="L443"> return finalizer.apply(new ImmutableTupleQueryResult(result));</span>
}
}
}
/*******************************************************************************************************************
*
* Facility method that creates an {@link IRI} given an {@link Id}.
*
* @param id the {@code Id}
* @return the {@code IRI}
*
******************************************************************************************************************/
@Nonnull
protected Value iriFor (@Nonnull final Id id)
{
<span class="fc" id="L459"> return FACTORY.createIRI(id.stringValue());</span>
}
/*******************************************************************************************************************
*
*
******************************************************************************************************************/
@Nonnull
protected Value literalFor (final boolean b)
{
<span class="nc" id="L469"> return FACTORY.createLiteral(b);</span>
}
/*******************************************************************************************************************
*
* Reads a SPARQL statement from a named resource
*
* @param clazz the reference class
* @param name the resource name
* @return the SPARQL statement
*
******************************************************************************************************************/
@Nonnull
protected static String readSparql (@Nonnull final Class<?> clazz, @Nonnull final String name)
{
<span class="fc" id="L484"> try (final InputStream is = clazz.getResourceAsStream(name))</span>
{
<span class="fc" id="L486"> return Stream.of(StreamUtils.copyToString(is, UTF_8)</span>
<span class="fc" id="L487"> .split("\n"))</span>
<span class="fc bfc" id="L488" title="All 2 branches covered."> .filter(s -> !s.matches(REGEX_COMMENT))</span>
<span class="fc" id="L489"> .collect(joining("\n"));</span>
}
<span class="nc" id="L491"> catch (IOException e)</span>
{
<span class="nc" id="L493"> throw new RuntimeException(e);</span>
}
}
/*******************************************************************************************************************
*
* Instantiates an entity for each given {@link TupleQueryResult}. Entities are instantiated in the DCI contents
* associated to this {@link Finder} - see {@link #getContexts()}.
*
* @param <E> the static type of the entities to instantiate
* @param repository the repository we're querying
* @param entityClass the dynamic type of the entities to instantiate
* #param queryResult the {@code TupleQueryResult}
* @return the instantiated entities
*
******************************************************************************************************************/
@Nonnull
private <E> List<E> createEntities (@Nonnull final Repository repository,
@Nonnull final Class<E> entityClass,
@Nonnull final TupleQueryResult queryResult)
{
<span class="fc" id="L514"> return contextManager.runWithContexts(getContexts(), new Task<>()</span>
<span class="fc" id="L515"> {</span>
@Override @Nonnull
public List<E> run()
{
<span class="fc" id="L519"> return streamOf(queryResult)</span>
<span class="fc" id="L520"> .map(bindingSet -> entityFactory.createEntity(repository, entityClass, bindingSet))</span>
<span class="fc" id="L521"> .collect(toList());</span>
}
});
// TODO: requires TheseFoolishThings 3.1-ALPHA-3
// return contextManager.runWithContexts(getContexts(), () -> streamOf(queryResult)
// .map(bindingSet -> entityFactory.createEntity(repository, entityClass, bindingSet))
// .collect(toList()));
}
/*******************************************************************************************************************
*
* Returns {@code true} if the given string contains a binding tag (in the form {@code @TAG@}) that matches one
* of the bindings; or if there are no binding tags. This is used as a filter to eliminate portions of SPARQL
* queries that don't match any binding.
*
* @param string the string
* @param bindings the bindings
* @return {@code true} if there is a match
*
******************************************************************************************************************/
private static boolean matchesTag (@Nonnull final String string, @Nonnull final Object[] bindings)
{
<span class="fc" id="L543"> final Pattern patternBindingTagLine = Pattern.compile(REGEX_BINDING_TAG_LINE);</span>
<span class="fc" id="L544"> final Matcher matcher = patternBindingTagLine.matcher(string);</span>
<span class="fc bfc" id="L546" title="All 2 branches covered."> if (!matcher.matches())</span>
{
<span class="fc" id="L548"> return true;</span>
}
<span class="fc" id="L551"> final String tag = matcher.group(1);</span>
<span class="fc bfc" id="L553" title="All 2 branches covered."> for (int i = 0; i < bindings.length; i+= 2)</span>
{
<span class="fc bfc" id="L555" title="All 2 branches covered."> if (tag.equals(bindings[i]))</span>
{
<span class="fc" id="L557"> return true;</span>
}
}
<span class="fc" id="L561"> return false;</span>
}
/*******************************************************************************************************************
*
* Logs the query at various detail levels.
*
* @param originalSparql the original SPARQL statement
* @param sparql the SPARQL statement after binding tag filtering
* @param bindings the bindings
*
******************************************************************************************************************/
private void log (@Nonnull final String originalSparql,
@Nonnull final String sparql,
@Nonnull final Object ... bindings)
{
<span class="pc bpc" id="L577" title="1 of 2 branches missed."> if (log.isTraceEnabled())</span>
{
<span class="nc" id="L579"> Stream.of(originalSparql.split("\n")).forEach(s -> log.trace(">>>> original query: {}", s));</span>
}
<span class="pc bpc" id="L582" title="1 of 2 branches missed."> if (log.isDebugEnabled())</span>
{
<span class="nc" id="L584"> Stream.of(sparql.split("\n")).forEach(s -> log.debug(">>>> query: {}", s));</span>
}
<span class="pc bpc" id="L587" title="2 of 4 branches missed."> if (!log.isDebugEnabled() && log.isInfoEnabled())</span>
{
<span class="fc" id="L589"> log.info(">>>> query: {}", compacted(sparql));</span>
}
<span class="pc bpc" id="L592" title="1 of 2 branches missed."> if (log.isInfoEnabled())</span>
{
<span class="fc" id="L594"> log.info(">>>> query parameters: {}", Arrays.toString(bindings));</span>
}
<span class="fc" id="L596"> }</span>
/*******************************************************************************************************************
*
*
******************************************************************************************************************/
@Nonnull
private static String compacted (@Nonnull final String sparql)
{
<span class="fc" id="L605"> return sparql.replace("\n", " ").replaceAll("\\s+", " ").trim();</span>
}
}
</pre><div class="footer"><span class="right">Created with <a href="http://www.jacoco.org/jacoco">JaCoCo</a> 0.8.7.202105040129</span></div></body></html>