From eedcd0e5c52a0d6407058928d0afa32db207f594 Mon Sep 17 00:00:00 2001
From: Steven Winship <39765413+stevenwinship@users.noreply.github.com>
Date: Wed, 24 Jun 2026 15:15:57 -0400
Subject: [PATCH] Extend Search API to include isLinked
---
conf/solr/schema.xml | 1 +
...11845-extend-search-api-return-islinked.md | 2 +
.../edu/harvard/iq/dataverse/Dataset.java | 43 +++-----
.../edu/harvard/iq/dataverse/Dataverse.java | 42 ++------
.../iq/dataverse/search/IndexServiceBean.java | 100 +++++-------------
.../iq/dataverse/search/SearchFields.java | 3 +
.../iq/dataverse/search/SolrSearchResult.java | 44 ++++----
.../search/SolrSearchServiceBean.java | 30 ++----
.../iq/dataverse/util/json/JsonPrinter.java | 12 +++
.../edu/harvard/iq/dataverse/api/LinkIT.java | 38 +++++--
10 files changed, 130 insertions(+), 185 deletions(-)
create mode 100644 doc/release-notes/11845-extend-search-api-return-islinked.md
diff --git a/conf/solr/schema.xml b/conf/solr/schema.xml
index 4d65b378485..905177d8c68 100644
--- a/conf/solr/schema.xml
+++ b/conf/solr/schema.xml
@@ -208,6 +208,7 @@
+
diff --git a/doc/release-notes/11845-extend-search-api-return-islinked.md b/doc/release-notes/11845-extend-search-api-return-islinked.md
new file mode 100644
index 00000000000..32bb7a7432b
--- /dev/null
+++ b/doc/release-notes/11845-extend-search-api-return-islinked.md
@@ -0,0 +1,2 @@
+## Feature Request ##
+Search API now returns "isLinked" in the JSON response for datasets and collections that are linked. "isLinked" will also be returned in direct dataset and collection lookups. This attribute will only be included when the value is true.
diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataset.java b/src/main/java/edu/harvard/iq/dataverse/Dataset.java
index 39dccdcd4ea..124ba1817f5 100644
--- a/src/main/java/edu/harvard/iq/dataverse/Dataset.java
+++ b/src/main/java/edu/harvard/iq/dataverse/Dataset.java
@@ -8,38 +8,17 @@
import edu.harvard.iq.dataverse.makedatacount.DatasetExternalCitations;
import edu.harvard.iq.dataverse.makedatacount.DatasetMetrics;
import edu.harvard.iq.dataverse.settings.FeatureFlags;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.sql.Timestamp;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import jakarta.persistence.CascadeType;
-import jakarta.persistence.ColumnResult;
-import jakarta.persistence.Entity;
-import jakarta.persistence.Index;
-import jakarta.persistence.JoinColumn;
-import jakarta.persistence.ManyToOne;
-import jakarta.persistence.NamedNativeQuery;
-import jakarta.persistence.NamedQueries;
-import jakarta.persistence.NamedQuery;
-import jakarta.persistence.OneToMany;
-import jakarta.persistence.OneToOne;
-import jakarta.persistence.OrderBy;
-import jakarta.persistence.SqlResultSetMapping;
-import jakarta.persistence.Table;
-import jakarta.persistence.Temporal;
-import jakarta.persistence.TemporalType;
-
import edu.harvard.iq.dataverse.settings.JvmSettings;
import edu.harvard.iq.dataverse.storageuse.StorageUse;
import edu.harvard.iq.dataverse.util.StringUtil;
import edu.harvard.iq.dataverse.util.SystemConfig;
+import jakarta.persistence.*;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Timestamp;
+import java.text.SimpleDateFormat;
+import java.util.*;
/**
*
@@ -810,13 +789,17 @@ public HarvestingClient getHarvestedFrom() {
public void setHarvestedFrom(HarvestingClient harvestingClientConfig) {
this.harvestedFrom = harvestingClientConfig;
}
-
+
public boolean isHarvested() {
return this.harvestedFrom != null;
}
+ public boolean isLinked() {
+ return (datasetLinkingDataverses != null && datasetLinkingDataverses.size() > 0);
+ }
+
private String harvestIdentifier;
-
+
public String getHarvestIdentifier() {
return harvestIdentifier;
}
diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java
index 31919398530..29a429f0034 100644
--- a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java
+++ b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java
@@ -1,44 +1,16 @@
package edu.harvard.iq.dataverse;
-import edu.harvard.iq.dataverse.dataverse.featured.DataverseFeaturedItem;
-import edu.harvard.iq.dataverse.harvest.client.HarvestingClient;
import edu.harvard.iq.dataverse.authorization.DataverseRole;
import edu.harvard.iq.dataverse.dataset.DatasetType;
+import edu.harvard.iq.dataverse.harvest.client.HarvestingClient;
import edu.harvard.iq.dataverse.search.savedsearch.SavedSearch;
import edu.harvard.iq.dataverse.storageuse.StorageUse;
import edu.harvard.iq.dataverse.util.BundleUtil;
import edu.harvard.iq.dataverse.util.SystemConfig;
+import jakarta.persistence.*;
+import jakarta.validation.constraints.*;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Objects;
-import java.util.Set;
-import jakarta.persistence.CascadeType;
-import jakarta.persistence.CollectionTable;
-import jakarta.persistence.Column;
-import jakarta.persistence.ElementCollection;
-import jakarta.persistence.Entity;
-import jakarta.persistence.EnumType;
-import jakarta.persistence.Enumerated;
-import jakarta.persistence.FetchType;
-import jakarta.persistence.Index;
-import jakarta.persistence.JoinColumn;
-import jakarta.persistence.JoinTable;
-import jakarta.persistence.ManyToMany;
-import jakarta.persistence.ManyToOne;
-import jakarta.persistence.NamedQueries;
-import jakarta.persistence.NamedQuery;
-import jakarta.persistence.OneToMany;
-import jakarta.persistence.OneToOne;
-import jakarta.persistence.OrderBy;
-import jakarta.persistence.Table;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotEmpty;
-import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Pattern;
-import jakarta.validation.constraints.Size;
+import java.util.*;
/**
*
@@ -327,7 +299,11 @@ public List getDatasetLinkingDataverses() {
public void setDatasetLinkingDataverses(List datasetLinkingDataverses) {
this.datasetLinkingDataverses = datasetLinkingDataverses;
}
-
+
+ public boolean isLinked() {
+ return (dataverseLinkingDataverses != null && dataverseLinkingDataverses.size() > 0);
+ }
+
public Set getDataverseSubjects() {
return dataverseSubjects;
}
diff --git a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java
index 8132c5e113d..443a8ac83f8 100644
--- a/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java
+++ b/src/main/java/edu/harvard/iq/dataverse/search/IndexServiceBean.java
@@ -1,37 +1,8 @@
package edu.harvard.iq.dataverse.search;
-import edu.harvard.iq.dataverse.ControlledVocabularyValue;
-import edu.harvard.iq.dataverse.CurationStatus;
-import edu.harvard.iq.dataverse.DataFile;
-import edu.harvard.iq.dataverse.DataFileServiceBean;
-import edu.harvard.iq.dataverse.DataFileTag;
-import edu.harvard.iq.dataverse.DataTable;
-import edu.harvard.iq.dataverse.Dataset;
-import edu.harvard.iq.dataverse.DatasetField;
-import edu.harvard.iq.dataverse.DatasetFieldCompoundValue;
-import edu.harvard.iq.dataverse.DatasetFieldConstant;
-import edu.harvard.iq.dataverse.DatasetFieldServiceBean;
-import edu.harvard.iq.dataverse.DatasetFieldType;
-import edu.harvard.iq.dataverse.DatasetFieldValue;
-import edu.harvard.iq.dataverse.DatasetFieldValueValidator;
-import edu.harvard.iq.dataverse.DatasetLinkingServiceBean;
-import edu.harvard.iq.dataverse.DatasetServiceBean;
-import edu.harvard.iq.dataverse.DatasetVersion;
+import edu.harvard.iq.dataverse.*;
import edu.harvard.iq.dataverse.DatasetVersion.VersionState;
-import edu.harvard.iq.dataverse.DatasetVersionFilesServiceBean;
-import edu.harvard.iq.dataverse.DatasetVersionServiceBean;
-import edu.harvard.iq.dataverse.Dataverse;
-import edu.harvard.iq.dataverse.DataverseLinkingServiceBean;
-import edu.harvard.iq.dataverse.DataverseServiceBean;
-import edu.harvard.iq.dataverse.DvObject;
import edu.harvard.iq.dataverse.DvObject.DType;
-import edu.harvard.iq.dataverse.DvObjectServiceBean;
-import edu.harvard.iq.dataverse.Embargo;
-import edu.harvard.iq.dataverse.FileMetadata;
-import edu.harvard.iq.dataverse.GlobalId;
-import edu.harvard.iq.dataverse.PermissionServiceBean;
-import edu.harvard.iq.dataverse.Retention;
-import edu.harvard.iq.dataverse.TermsOfUseAndAccess;
import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUserServiceBean;
import edu.harvard.iq.dataverse.batch.util.LoggingUtil;
@@ -50,51 +21,12 @@
import edu.harvard.iq.dataverse.util.FileUtil;
import edu.harvard.iq.dataverse.util.StringUtil;
import edu.harvard.iq.dataverse.util.SystemConfig;
-import java.io.IOException;
-import java.io.InputStream;
-import java.sql.Timestamp;
-import java.text.SimpleDateFormat;
-import java.time.LocalDate;
-import java.time.ZoneOffset;
-import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeParseException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Calendar;
-import java.util.Collection;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.Future;
-import java.util.concurrent.Semaphore;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.function.Function;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-import jakarta.ejb.AsyncResult;
-import jakarta.ejb.Asynchronous;
-import jakarta.ejb.EJB;
-import jakarta.ejb.EJBException;
-import jakarta.ejb.Stateless;
-import jakarta.ejb.TransactionAttribute;
-
-import static jakarta.ejb.TransactionAttributeType.REQUIRES_NEW;
-
+import jakarta.ejb.*;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.json.JsonObject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
-import jakarta.persistence.Query;
-
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.util.Strings;
@@ -107,8 +39,8 @@
import org.apache.solr.common.SolrDocumentList;
import org.apache.solr.common.SolrInputDocument;
import org.apache.solr.common.params.CursorMarkParams;
-import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.sax.BodyContentHandler;
import org.eclipse.microprofile.metrics.MetricUnits;
@@ -116,6 +48,27 @@
import org.eclipse.microprofile.metrics.annotation.Metric;
import org.xml.sax.ContentHandler;
+import java.io.IOException;
+import java.io.InputStream;
+import java.sql.Timestamp;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import static jakarta.ejb.TransactionAttributeType.REQUIRES_NEW;
+
@Stateless
@Named
public class IndexServiceBean {
@@ -248,6 +201,9 @@ public Future indexDataverse(Dataverse dataverse, boolean processPaths)
solrInputDocument.addField(SearchFields.METADATA_SOURCE, rootDataverse.getName()); //rootDataverseName);
/*}*/
+ solrInputDocument.addField(SearchFields.IS_LINKED, dataverse.isLinked());
+
+
addDataverseReleaseDateToSolrDoc(solrInputDocument, dataverse);
// if (dataverse.getOwner() != null) {
// solrInputDocument.addField(SearchFields.HOST_DATAVERSE,
@@ -1063,6 +1019,8 @@ public SolrInputDocuments toSolrDocs(IndexableDataset indexableDataset, Set collections;
+ private Boolean isLinked;
+
// private boolean statePublished;
/**
* @todo Investigate/remove this "unpublishedState" variable. For files that
@@ -582,9 +576,10 @@ public JsonObjectBuilder json(boolean showRelevance, boolean showEntityIds, bool
.add("restricted", this.fileRestricted)
.add("variables", this.tabularDataCount)
.add("observations", this.observations)
- .add("canDownloadFile", this.canDownloadFile);
+ .add("canDownloadFile", this.canDownloadFile)
+ .add("isLinked", this.isLinked);
- // Now that nullSafeJsonBuilder has been instatiated, check for null before adding to it!
+ // Now that nullSafeJsonBuilder has been instantiated, check for null before adding to it!
if (showRelevance) {
nullSafeJsonBuilder.add("matches", getRelevance());
nullSafeJsonBuilder.add("score", getScore());
@@ -1135,6 +1130,15 @@ public Boolean getFileRestricted() {
public void setFileRestricted(Boolean fileRestricted) {
this.fileRestricted = fileRestricted;
}
+
+ public Boolean isLinked() {
+ return isLinked;
+ }
+
+ public void setLinked(Boolean isLinked) {
+ this.isLinked = isLinked;
+ }
+
public Boolean getCanDownloadFile() {
return canDownloadFile;
}
diff --git a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java
index 401bd58a70b..a25375073c4 100644
--- a/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java
+++ b/src/main/java/edu/harvard/iq/dataverse/search/SolrSearchServiceBean.java
@@ -12,34 +12,13 @@
import edu.harvard.iq.dataverse.settings.FeatureFlags;
import edu.harvard.iq.dataverse.util.BundleUtil;
import edu.harvard.iq.dataverse.util.SystemConfig;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Calendar;
-import java.util.Collections;
-import java.util.Date;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.ListIterator;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.MissingResourceException;
-import java.util.logging.Level;
-import java.util.logging.Logger;
import jakarta.ejb.EJB;
import jakarta.ejb.EJBTransactionRolledbackException;
import jakarta.ejb.Stateless;
import jakarta.ejb.TransactionRolledbackLocalException;
import jakarta.inject.Inject;
import jakarta.inject.Named;
-import jakarta.json.Json;
-import jakarta.json.JsonArrayBuilder;
import jakarta.persistence.NoResultException;
-
import org.apache.commons.lang3.StringUtils;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.SolrQuery.SortClause;
@@ -52,6 +31,12 @@
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.*;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
@Stateless
@Named
public class SolrSearchServiceBean implements SearchService {
@@ -544,6 +529,9 @@ public SolrQueryResponse search(
if (Boolean.TRUE.equals((Boolean) solrDocument.getFieldValue(SearchFields.IS_HARVESTED))) {
solrSearchResult.setHarvested(true);
}
+ if (Boolean.TRUE.equals(solrDocument.getFieldValue(SearchFields.IS_LINKED))) {
+ solrSearchResult.setLinked(true);
+ }
solrSearchResult.setEmbargoEndDate(embargoEndDate);
solrSearchResult.setRetentionEndDate(retentionEndDate);
diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java
index 5c8b4974b3d..192b43df344 100644
--- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java
+++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java
@@ -358,6 +358,10 @@ public static JsonObjectBuilder json(Dataverse dv, Boolean hideEmail, Boolean re
bld.add("effectiveRequiresFilesToPublishDataset", dv.getEffectiveRequiresFilesToPublishDataset());
bld.add("isReleased", dv.isReleased());
+ if (dv.isLinked()) {
+ bld.add("isLinked", true);
+ }
+
List inputLevels = dv.getDataverseFieldTypeInputLevels();
if (!inputLevels.isEmpty()) {
bld.add("inputLevels", JsonPrinter.jsonDataverseFieldTypeInputLevels(inputLevels));
@@ -610,6 +614,10 @@ public static JsonObjectBuilder json(Dataset ds, Boolean returnOwners) {
}
bld.add("datasetType", ds.getDatasetType().getName());
+ if (ds.isLinked()) {
+ bld.add("isLinked", true);
+ }
+
JsonArrayBuilder locksArrayBuilder = Json.createArrayBuilder();
for (DatasetLock lock : ds.getLocks()) {
locksArrayBuilder.add(lock.getReason().toString());
@@ -677,6 +685,10 @@ public static JsonObjectBuilder json(DatasetVersion dsv, List anonymized
.add("publicationDate", dataset.getPublicationDateFormattedYYYYMMDD())
.add("citationDate", dataset.getCitationDateFormattedYYYYMMDD())
.add("versionNote", dsv.getVersionNote());
+
+ if (dsv.getDataset().isLinked()) {
+ bld.add("isLinked", true);
+ }
if (dataset.getGuestbook() != null) {
bld.add("guestbookId", dataset.getGuestbook().getId());
}
diff --git a/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java b/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java
index dfc132c3b3f..6ea27fe07a2 100644
--- a/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java
+++ b/src/test/java/edu/harvard/iq/dataverse/api/LinkIT.java
@@ -3,22 +3,19 @@
import io.restassured.RestAssured;
import io.restassured.path.json.JsonPath;
import io.restassured.response.Response;
+import jakarta.json.Json;
+import jakarta.json.JsonArray;
+import jakarta.json.JsonObject;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
import java.io.StringReader;
-import java.util.ArrayList;
-import java.util.List;
import java.util.logging.Logger;
import static jakarta.ws.rs.core.Response.Status.*;
-import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.equalTo;
-import static org.junit.jupiter.api.Assertions.*;
-
-import jakarta.json.Json;
-import jakarta.json.JsonArray;
-import jakarta.json.JsonObject;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class LinkIT {
@@ -88,6 +85,13 @@ public void testLinkedDataset() {
assertEquals("Darwin's Finches", JsonPath.from(getLinksResponse.asString()).getString("data.linkedDatasets[0].title"));
assertEquals(datasetPid, JsonPath.from(getLinksResponse.asString()).getString("data.linkedDatasets[0].identifier"));
+ // Test that search shows the "isLinked" attribute in the result
+ Response searchResponse = UtilIT.search("id:dataset_" + datasetId + "_draft", superuserApiToken);
+ searchResponse.prettyPrint();
+ searchResponse.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.items[0].isLinked", equalTo(true));
+
// A dataset cannot be linked to its parent dataverse.
Response tryToLinkToParentDataverse = UtilIT.linkDataset(datasetPid, dataverse1Alias, superuserApiToken);
tryToLinkToParentDataverse.prettyPrint();
@@ -150,11 +154,25 @@ public void testCreateDeleteDataverseLink() {
.body("data.linkedDataverses[0].alias", equalTo(dataverseAlias))
.body("data.linkedDataverses[0].displayName", equalTo(dataverseAlias));
+ // Test that search shows the "isLinked" attribute in the result
+ Response searchResponse = UtilIT.search("dvAlias:" + dataverseAlias, apiToken);
+ searchResponse.prettyPrint();
+ searchResponse.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.items[0].isLinked", equalTo(true));
+
Response deleteLinkingDataverseResponse = UtilIT.deleteDataverseLink(dataverseAlias, dataverseAlias2, apiToken);
deleteLinkingDataverseResponse.prettyPrint();
deleteLinkingDataverseResponse.then().assertThat()
.statusCode(OK.getStatusCode())
.body("data.message", equalTo("Link from Dataverse " + dataverseAlias + " to linked Dataverse " + dataverseAlias2 + " deleted"));
+
+ // Test that search no longer shows the "isLinked" attribute in the result
+ searchResponse = UtilIT.search("dvAlias:" + dataverseAlias, apiToken);
+ searchResponse.prettyPrint();
+ searchResponse.then().assertThat()
+ .statusCode(OK.getStatusCode())
+ .body("data.items[0].isLinked", equalTo(null));
}
@Test