Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,18 @@ public static String getLatestVersionId(String nanopubId) {
return cached != null ? cached.getRight() : nanopubId;
}

/**
* Drops the memoized latest-version lookup for a nanopub, so that the next
* {@link #getLatestVersionId(String)} goes back to the query API instead of answering
* from a memo that can be up to a minute old. For the places where the user explicitly
* asks for current data, such as {@link View#refreshLatestVersion(String)}.
*
* @param nanopubId The ID of the nanopublication.
*/
public static void forgetLatestVersion(String nanopubId) {
latestVersionMap.remove(nanopubId);
}

/**
* Checks whether the given nanopublication has been loaded by the query services,
* with a single cheap indexed lookup. A negative answer only means the instance that
Expand Down
68 changes: 66 additions & 2 deletions src/main/java/com/knowledgepixels/nanodash/View.java
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ public static View get(String id) {
* @return the View object
*/
public static View get(String id, boolean resolveLatest) {
String npId = id.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1");
String npId = toNanopubId(id);
if (!resolveLatest) {
View exact = getExactVersion(id, npId);
if (exact == null || exact.getGoverningSpace() == null || exact.getViewKindIri() == null) {
Expand All @@ -223,6 +223,70 @@ public static View get(String id, boolean resolveLatest) {
return resolved;
}

/**
* Re-resolves the latest version of a view, going back to the query API instead of
* trusting what is memoized. This is what lets a view display's "refresh now" bring the
* <em>view</em> up to date and not just its results (issue #654): a memoized resolution
* is only re-checked once a minute in the background, and a display whose view was
* resolved server-side by the {@code get-view-displays} query carries an exact version
* that is never re-checked at all, so a newly published version of the view would
* otherwise not show up until the page's structure happened to be refreshed.
* <p>
* Every memoized resolution leading to the given version is dropped along with the
* lookups behind it, so that pages reaching this view by another id — a built-in view is
* looked up by the id hard-coded for it, not by the version that id resolves to —
* re-resolve it on their next render too.
*
* @param id the id of the view version currently shown
* @return the view's current latest version, which is the given one when there is no
* newer version or the lookup fails, or null if the view cannot be loaded at all
*/
public static View refreshLatestVersion(String id) {
// The ids whose lookups are to be forgotten: the given one, plus every memo key
// that leads to it.
Set<String> staleIds = new HashSet<>();
staleIds.add(id);
for (Map.Entry<String, Pair<Long, View>> memo : latestResolvedViews.asMap().entrySet()) {
View memoized = memo.getValue().getRight();
if (memo.getKey().equals(id) || (memoized != null && id.equals(memoized.getId()))) {
latestResolvedViews.invalidate(memo.getKey());
staleIds.add(memo.getKey());
}
}
for (String staleId : staleIds) forgetLatestVersionLookup(staleId);
View resolved = resolveLatestVersion(id, toNanopubId(id));
if (resolved != null) {
latestResolvedViews.put(id, Pair.of(System.currentTimeMillis(), resolved));
}
return resolved;
}

/**
* Marks the version lookup behind a view id as outdated, so that the next resolution
* asks the API instead of answering from what it holds: the governed-version query for
* a view that floats within its space, the supersedes-chain lookup (its memo and its
* cached response both) for one that does not.
*/
private static void forgetLatestVersionLookup(String viewId) {
String npId = toNanopubId(viewId);
View pinned = getExactVersion(viewId, npId);
if (pinned != null && pinned.getGoverningSpace() != null && pinned.getViewKindIri() != null) {
ApiCache.clearCache(GovernedVersions.getQueryRef(
pinned.getViewKindIri().stringValue(), pinned.getGoverningSpace().stringValue()), 0);
} else {
QueryApiAccess.forgetLatestVersion(npId);
ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_LATEST_VERSION_OF_NP, "np", npId), 0);
}
}

/**
* The id of the nanopub a view id belongs to: the view id up to and including its
* artifact code. An id that is already a nanopub id is returned unchanged.
*/
private static String toNanopubId(String viewId) {
return viewId.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1");
}

/**
* Resolves a view id to the latest version of its view definition, falling
* back to the exact given version if the lookup fails or doesn't yield a
Expand Down Expand Up @@ -276,7 +340,7 @@ private static View resolveGovernedVersion(View pinned) {
String latestId = GovernedVersions.getLatestVersionIriSync(
pinned.getViewKindIri().stringValue(), pinned.getGoverningSpace().stringValue());
if (latestId != null && !latestId.equals(pinned.getId())) {
String latestNpId = latestId.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1");
String latestNpId = toNanopubId(latestId);
View resolved = getExactVersion(latestId, latestNpId);
if (resolved != null) return resolved;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.knowledgepixels.nanodash.NavigationContext;
import com.knowledgepixels.nanodash.QueryResult;
import com.knowledgepixels.nanodash.Utils;
import com.knowledgepixels.nanodash.View;
import com.knowledgepixels.nanodash.ViewDisplay;
import com.knowledgepixels.nanodash.component.GuidedChoiceItem;
import com.knowledgepixels.nanodash.component.RefreshingResultPanel;
Expand Down Expand Up @@ -198,14 +199,47 @@ protected void onConfigure() {
addToOwnLink.setVisible(showAddToOwn);
addEntry("addToOwn", addToOwnLink);

// The version of the view definition this display is showing. A newer one can have
// been published since the page was built, which "refresh now" checks for below.
final String shownViewId = viewDisplay.getView() == null ? null : viewDisplay.getView().getId();

// Refreshes this one view where it stands. Re-rendering the whole page would work too,
// but it takes the reader back to the top of it, away from the view they were looking
// at — and re-runs everything else on the page for a refresh they asked of one view.
AjaxFallbackLink<Void> refreshLink = new AjaxFallbackLink<>("refreshNow") {
@Override
public void onClick(Optional<AjaxRequestTarget> target) {
ApiCache.clearCache(queryRef, 0);
// Bringing a view up to date is not only a matter of re-running its query:
// the view definition itself can have been superseded since this page was
// built, and neither the memoized resolution nor the version the page's
// structure resolved to would notice on their own (issue #654).
View latestView = shownViewId == null ? null : View.refreshLatestVersion(shownViewId);
if (latestView != null && !shownViewId.equals(latestView.getId())) {
// A new version can change everything the display is made of — its query,
// its columns, its actions, its width — which is more than the piece on
// screen can be patched into. The version in use comes from the page's
// structure (the get-view-displays query resolves it server-side), so the
// structure is what has to be asked again: the same route the page-level
// "refresh now" takes, with the current structure kept on screen under a
// spinner until the refreshed one lands.
AbstractResourceWithProfile r = pageResourceId.isEmpty()
? null : AbstractResourceWithProfile.get(pageResourceId);
if (r != null) r.forceRefresh(0);
setResponsePage(getPage().getClass(), getPage().getPageParameters());
return;
}
QueryResult view = findParent(QueryResult.class);
if (view == null && target.isPresent()) {
// Not every view display puts results in the page. A query-form view
// shows a form, and the results it leads to live on the page it submits
// to, so there is nothing here to bring up to date: the query has just
// been marked outdated for that next submit, and the view definition has
// been re-checked above. Re-rendering the page on top of that would
// repaint everything for no visible change — which is what made these
// views, alone among the display types, flicker on every refresh.
return;
}
// A view is not always what stands in the page: while it waits for its first
// results it is inside Wicket's lazy-loading panel, and while it is being
// brought up to date inside a RefreshingResultPanel. Either way the wrapper
Expand Down
83 changes: 83 additions & 0 deletions src/test/java/com/knowledgepixels/nanodash/ViewTest.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
package com.knowledgepixels.nanodash;

import org.apache.commons.lang3.tuple.Pair;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.nanopub.MalformedNanopubException;
import org.nanopub.Nanopub;
import org.nanopub.NanopubImpl;

import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;

class ViewTest {

// Three versions of the same header view: the original a page might reference by a
// hard-coded id, the version that superseded it, and the one published after that.
private static final String NP_V0 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV0";
private static final String NP_V1 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV1";
private static final String NP_V2 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV2";
private static final String VIEW_V0 = NP_V0 + "/view";
private static final String VIEW_V1 = NP_V1 + "/view";
private static final String VIEW_V2 = NP_V2 + "/view";

private static Nanopub load(String fileName) throws MalformedNanopubException, IOException {
return new NanopubImpl(new File("src/test/resources/" + fileName), RDFFormat.TRIG);
}

@Test
void parseMappingLiteralSplitsOnWhitespace() {
// single mapping
Expand All @@ -31,4 +57,61 @@ void parseMappingLiteralHandlesVoidAndEmpty() {
assertEquals(List.of("a:foo"), View.parseMappingLiteral("a:foo void"));
}

/**
* The point of issue #654: a view display shows the version its page resolved to, and
* that version can have been superseded since. Asking for the view to be refreshed has
* to go back to the API, even where a resolution was memoized moments ago.
*/
@Test
void refreshLatestVersionPicksUpASupersedingVersion() throws Exception {
Nanopub v1 = load("np-header-view-v1.trig");
Nanopub v2 = load("np-header-view-v2.trig");
try (MockedStatic<Utils> utils = mockStatic(Utils.class);
MockedStatic<QueryApiAccess> api = mockStatic(QueryApiAccess.class);
MockedStatic<ApiCache> cache = mockStatic(ApiCache.class)) {
utils.when(() -> Utils.getAsNanopub(NP_V1)).thenReturn(v1);
utils.when(() -> Utils.getAsNanopub(NP_V2)).thenReturn(v2);
api.when(() -> QueryApiAccess.getLatestVersionId(NP_V1)).thenReturn(NP_V2);

View shown = View.get(VIEW_V1, false);
assertEquals("First version", shown.getTitle());

View refreshed = View.refreshLatestVersion(VIEW_V1);

assertEquals(VIEW_V2, refreshed.getId());
assertEquals("Second version", refreshed.getTitle());
}
}

/**
* A page can reach the same view by another id — a built-in view is looked up by the id
* hard-coded for it, which the memo maps to whatever that id resolves to. Refreshing the
* shown version has to drop those memos too, or the next render of such a page would put
* the superseded version back on screen.
*/
@Test
void refreshLatestVersionDropsMemosLeadingToTheRefreshedVersion() throws Exception {
Nanopub v0 = load("np-header-view-v0.trig");
Nanopub v1 = load("np-header-view-v1.trig");
Nanopub v2 = load("np-header-view-v2.trig");
try (MockedStatic<Utils> utils = mockStatic(Utils.class);
MockedStatic<QueryApiAccess> api = mockStatic(QueryApiAccess.class);
MockedStatic<ApiCache> cache = mockStatic(ApiCache.class)) {
utils.when(() -> Utils.getAsNanopub(NP_V0)).thenReturn(v0);
utils.when(() -> Utils.getAsNanopub(NP_V1)).thenReturn(v1);
utils.when(() -> Utils.getAsNanopub(NP_V2)).thenReturn(v2);
api.when(() -> QueryApiAccess.getLatestVersionId(NP_V1)).thenReturn(NP_V2);

// The hard-coded id V0 was resolved to V1 a moment ago and memoized as such.
Map<String, Pair<Long, View>> memo = new HashMap<>();
memo.put(VIEW_V0, Pair.of(System.currentTimeMillis(), View.get(VIEW_V1, false)));
View.importResolvedViews(memo, Long.MAX_VALUE);
assertTrue(View.isCached(VIEW_V0));

View.refreshLatestVersion(VIEW_V1);

assertFalse(View.isCached(VIEW_V0));
}
}

}
35 changes: 35 additions & 0 deletions src/test/resources/np-header-view-v0.trig
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@prefix this: <https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV0> .
@prefix sub: <https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV0/> .
@prefix np: <http://www.nanopub.org/nschema#> .
@prefix gen: <https://w3id.org/kpxl/gen/terms/> .
@prefix dct: <http://purl.org/dc/terms/> .
@prefix npx: <http://purl.org/nanopub/x/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix orcid: <https://orcid.org/> .
@prefix prov: <http://www.w3.org/ns/prov#> .

sub:Head {
this: a np:Nanopublication;
np:hasAssertion sub:assertion;
np:hasProvenance sub:provenance;
np:hasPublicationInfo sub:pubinfo .
}

# The original version of the view, the id a page with a hard-coded view reference looks
# it up by; superseded by np-header-view-v1.trig.
sub:assertion {
sub:view a gen:ResourceView, gen:HeaderView;
rdfs:label "header-view";
dct:title "Original version" .
}

sub:provenance {
sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 .
}

sub:pubinfo {
this: dct:created "2026-08-28T08:00:00.000Z"^^xsd:dateTime;
dct:creator orcid:0000-0002-1267-0234;
npx:embeds sub:view .
}
35 changes: 35 additions & 0 deletions src/test/resources/np-header-view-v1.trig
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@prefix this: <https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV1> .
@prefix sub: <https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV1/> .
@prefix np: <http://www.nanopub.org/nschema#> .
@prefix gen: <https://w3id.org/kpxl/gen/terms/> .
@prefix dct: <http://purl.org/dc/terms/> .
@prefix npx: <http://purl.org/nanopub/x/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix orcid: <https://orcid.org/> .
@prefix prov: <http://www.w3.org/ns/prov#> .

sub:Head {
this: a np:Nanopublication;
np:hasAssertion sub:assertion;
np:hasProvenance sub:provenance;
np:hasPublicationInfo sub:pubinfo .
}

# A header view, i.e. the one display type that carries no query (issue #572), so that
# loading it does not pull in a query nanopub.
sub:assertion {
sub:view a gen:ResourceView, gen:HeaderView;
rdfs:label "header-view";
dct:title "First version" .
}

sub:provenance {
sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 .
}

sub:pubinfo {
this: dct:created "2026-08-28T09:00:00.000Z"^^xsd:dateTime;
dct:creator orcid:0000-0002-1267-0234;
npx:embeds sub:view .
}
35 changes: 35 additions & 0 deletions src/test/resources/np-header-view-v2.trig
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@prefix this: <https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV2> .
@prefix sub: <https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV2/> .
@prefix np: <http://www.nanopub.org/nschema#> .
@prefix gen: <https://w3id.org/kpxl/gen/terms/> .
@prefix dct: <http://purl.org/dc/terms/> .
@prefix npx: <http://purl.org/nanopub/x/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix orcid: <https://orcid.org/> .
@prefix prov: <http://www.w3.org/ns/prov#> .

sub:Head {
this: a np:Nanopublication;
np:hasAssertion sub:assertion;
np:hasProvenance sub:provenance;
np:hasPublicationInfo sub:pubinfo .
}

# The superseding version of np-header-view-v1.trig.
sub:assertion {
sub:view a gen:ResourceView, gen:HeaderView;
rdfs:label "header-view";
dct:title "Second version" .
}

sub:provenance {
sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 .
}

sub:pubinfo {
this: dct:created "2026-08-28T10:00:00.000Z"^^xsd:dateTime;
dct:creator orcid:0000-0002-1267-0234;
npx:embeds sub:view;
npx:supersedes <https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV1> .
}