• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanodash / 29227150127

13 Jul 2026 05:46AM UTC coverage: 28.504% (+0.02%) from 28.481%
29227150127

push

github

web-flow
Merge pull request #552 from knowledgepixels/fix/apicache-replaced-eviction-npe

fix: prevent ApiCache metadata wipe on entry replacement causing home page hang

1872 of 7381 branches covered (25.36%)

Branch coverage included in aggregate %.

3777 of 12437 relevant lines covered (30.37%)

4.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

14.35
src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java
1
package com.knowledgepixels.nanodash.domain;
2

3
import com.google.common.collect.ArrayListMultimap;
4
import com.google.common.collect.Multimap;
5
import com.knowledgepixels.nanodash.ApiCache;
6
import com.knowledgepixels.nanodash.NanodashThreadPool;
7
import com.knowledgepixels.nanodash.QueryApiAccess;
8
import com.knowledgepixels.nanodash.ViewDisplay;
9
import com.knowledgepixels.nanodash.repository.SpaceRepository;
10
import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS;
11
import org.eclipse.rdf4j.model.IRI;
12
import org.nanopub.Nanopub;
13
import org.nanopub.extra.services.ApiResponse;
14
import org.nanopub.extra.services.ApiResponseEntry;
15
import org.nanopub.extra.services.QueryRef;
16
import org.slf4j.Logger;
17
import org.slf4j.LoggerFactory;
18

19
import java.io.Serializable;
20
import java.util.*;
21
import java.util.concurrent.ConcurrentHashMap;
22
import java.util.concurrent.Future;
23

24
/**
25
 * Abstract class representing a resource with a profile in the Nanodash application.
26
 * This class provides common functionality for resources that have associated profiles, such as spaces and users.
27
 */
28
public abstract class AbstractResourceWithProfile implements Serializable, ResourceWithProfile {
29

30
    private static final Logger logger = LoggerFactory.getLogger(AbstractResourceWithProfile.class);
9✔
31

32
    private static final Map<Class<?>, Map<String, AbstractResourceWithProfile>> instances = new ConcurrentHashMap<>();
15✔
33

34
    // Backoff after a failed data update, so a persistently failing resource
35
    // doesn't respawn an update task on every page poll (~1/s per open page).
36
    private static final long FAILED_UPDATE_BACKOFF_MS = 10 * 1000;
37

38
    private final String id;
39
    private Space space;
40
    private ResourceWithProfile data = new ResourceWithProfile();
15✔
41
    private volatile boolean dataInitialized = false;
9✔
42
    private volatile boolean dataNeedsUpdate = true;
9✔
43
    private volatile Long runUpdateAfter = null;
9✔
44

45
    /**
46
     * Inner class to hold the data associated with a resource, including its view displays.
47
     */
48
    protected static class ResourceWithProfile implements Serializable {
6✔
49
        List<ViewDisplay> viewDisplays = new ArrayList<>();
18✔
50
    }
51

52
    /**
53
     * Checks if a resource with the given unique identifier exists in the system.
54
     *
55
     * @param id the unique identifier of the resource
56
     * @return true if a resource with the given id exists, false otherwise
57
     */
58
    public static boolean isResourceWithProfile(String id) {
59
        return get(id) != null;
×
60
    }
61

62
    /**
63
     * Retrieves an instance of AbstractResourceWithProfile by its unique identifier.
64
     *
65
     * @param id the unique identifier of the resource
66
     * @return the AbstractResourceWithProfile instance associated with the given id, or null if no such instance exists
67
     */
68
    public static AbstractResourceWithProfile get(String id) {
69
        for (Map<String, AbstractResourceWithProfile> map : instances.values()) {
33✔
70
            if (map.containsKey(id)) {
12✔
71
                return map.get(id);
10✔
72
            }
73
        }
3✔
74
        return null;
6✔
75
    }
76

77
    /**
78
     * Constructor for AbstractResourceWithProfile.
79
     *
80
     * @param id the unique identifier for this resource
81
     */
82
    protected AbstractResourceWithProfile(String id) {
6✔
83
        this.id = id;
9✔
84
        instances.computeIfAbsent(getClass(), k -> new ConcurrentHashMap<>()).put(id, this);
42✔
85
    }
3✔
86

87
    /**
88
     * Removes an instance of AbstractResourceWithProfile from the instances map based on its type and unique identifier.
89
     *
90
     * @param type the class type of the resource to remove
91
     * @param id   the unique identifier of the resource to remove
92
     */
93
    protected static void removeInstance(Class<?> type, String id) {
94
        Map<String, AbstractResourceWithProfile> map = instances.get(type);
×
95
        if (map != null) {
×
96
            map.remove(id);
×
97
        }
98
    }
×
99

100
    /**
101
     * Retrieves all instances of AbstractResourceWithProfile of a specific type.
102
     *
103
     * @param type the class type of the resources to retrieve
104
     * @return a map of resource IDs to AbstractResourceWithProfile instances of the specified type, or an empty map if no instances exist for that type
105
     */
106
    protected static Map<String, AbstractResourceWithProfile> getInstances(Class<?> type) {
107
        return instances.getOrDefault(type, Collections.emptyMap());
18✔
108
    }
109

110
    /**
111
     * Initializes the space for this resource.
112
     *
113
     * @param space the space to associate with this resource
114
     */
115
    protected void initSpace(Space space) {
116
        this.space = space;
9✔
117
        logger.info("Initialized space {} for resource {}", space.getId(), id);
21✔
118
    }
3✔
119

120
    @Override
121
    public String getId() {
122
        return id;
9✔
123
    }
124

125
    @Override
126
    public synchronized Future<?> triggerDataUpdate() {
127
        if (dataNeedsUpdate) {
×
128
            // Not due yet (delayed forceRefresh or backoff after a failure): don't
129
            // occupy a pool thread with sleeping; a later access re-triggers.
130
            Long after = runUpdateAfter;
×
131
            if (after != null && System.currentTimeMillis() < after) {
×
132
                return null;
×
133
            }
134
            runUpdateAfter = null;
×
135
            logger.info("Data needs update for resource {}, starting update thread", id);
×
136
            dataNeedsUpdate = false;
×
137
            return NanodashThreadPool.submit(() -> {
×
138
                try {
139
                    ResourceWithProfile newData = new ResourceWithProfile();
×
140

141
                    // The query returns both standalone view displays (bound ?display) and
142
                    // preset-supplied views (issue #302: unbound ?display, the assignment's
143
                    // preset expanded into its views server-side), already ordered by date
144
                    // (latest first) so the per-view-kind latest-wins / deactivation
145
                    // aggregation in getViewDisplays() resolves overrides between presets and
146
                    // standalone displays correctly, in either direction.
147
                    // For a space, scope the displays to its representative ref (root nanopub) so a
148
                    // multi-ref identifier doesn't merge displays across rival definitions; other
149
                    // resource kinds (and spaces with no known ref root) stay IRI-keyed.
150
                    String vdRefRoot = getViewDisplayRefRoot();
×
151
                    QueryRef vdQuery = (vdRefRoot != null && !vdRefRoot.isEmpty())
×
152
                            ? viewDisplaysRefQueryRef(vdRefRoot)
×
153
                            : new QueryRef(QueryApiAccess.GET_VIEW_DISPLAYS, "resource", id);
×
154
                    newData.viewDisplays.addAll(buildViewDisplays(vdQuery));
×
155
                    data = newData;
×
156
                    dataInitialized = true;
×
157
                } catch (Exception ex) {
×
158
                    logger.error("Error while trying to update data for resource {}", id, ex);
×
159
                    runUpdateAfter = System.currentTimeMillis() + FAILED_UPDATE_BACKOFF_MS;
×
160
                    dataNeedsUpdate = true;
×
161
                }
×
162
            });
×
163
        }
164
        return null;
×
165
    }
166

167
    /**
168
     * Forces a refresh of the resource data after a specified delay.
169
     *
170
     * @param waitMillis the delay in milliseconds before the data refresh is triggered
171
     */
172
    public void forceRefresh(long waitMillis) {
173
        logger.info("Forcing refresh of resource {} after {} ms", id, waitMillis);
×
174
        dataNeedsUpdate = true;
×
175
        dataInitialized = false;
×
176
        runUpdateAfter = System.currentTimeMillis() + waitMillis;
×
177
    }
×
178

179
    /**
180
     * Static method to force a refresh of the resource data for all instances of AbstractResourceWithProfile.
181
     */
182
    public static void refresh() {
183
        instances.values().forEach(map -> map.values().forEach(AbstractResourceWithProfile::setDataNeedsUpdate));
12✔
184
    }
3✔
185

186
    @Override
187
    public Long getRunUpdateAfter() {
188
        return runUpdateAfter;
×
189
    }
190

191
    @Override
192
    public Space getSpace() {
193
        return space;
×
194
    }
195

196
    @Override
197
    public abstract String getNanopubId();
198

199
    @Override
200
    public abstract Nanopub getNanopub();
201

202
    public abstract String getNamespace();
203

204
    @Override
205
    public void setDataNeedsUpdate() {
206
        dataNeedsUpdate = true;
9✔
207
    }
3✔
208

209
    @Override
210
    public boolean isDataInitialized() {
211
        triggerDataUpdate();
×
212
        return dataInitialized;
×
213
    }
214

215
    @Override
216
    public List<ViewDisplay> getViewDisplays() {
217
        logger.info("Getting view displays for resource {}", id);
×
218
        return data.viewDisplays;
×
219
    }
220

221
    @Override
222
    public List<ViewDisplay> getTopLevelViewDisplays() {
223
        // Pass the resource's own type(s) so that views targeting that type (e.g. a
224
        // messages view for gen:MaintainedResource) are shown at the top level, while
225
        // views targeting part types (e.g. gen:hasViewTargetClass owl:Class) are not.
226
        return getViewDisplays(true, getId(), getOwnClasses());
×
227
    }
228

229
    /**
230
     * The resource's own type IRI(s), used to match top-level views by
231
     * {@code gen:appliesToInstancesOf}. Empty by default; overridden per resource type.
232
     *
233
     * @return the resource's own classes (never null)
234
     */
235
    protected Set<IRI> getOwnClasses() {
236
        return Collections.emptySet();
×
237
    }
238

239
    @Override
240
    public List<ViewDisplay> getPartLevelViewDisplays(String resourceId, Set<IRI> classes) {
241
        return getViewDisplays(false, resourceId, classes);
×
242
    }
243

244
    private List<ViewDisplay> getViewDisplays(boolean toplevel, String resourceId, Set<IRI> classes) {
245
        triggerDataUpdate();
×
246
        return filterViewDisplays(getViewDisplays(), toplevel, resourceId, classes);
×
247
    }
248

249
    /**
250
     * Top-level view displays scoped to a specific space ref (root nanopub), fetched on demand
251
     * rather than from the IRI-keyed singleton data — used to render the Content tab of a
252
     * {@code ?root=}-pinned space page so it shows only that ref's displays. Falls back to the
253
     * default (singleton) displays when {@code refRoot} is null/empty. See docs/space-ref-identity.md.
254
     *
255
     * @param refRoot the ref's root nanopub, or null/empty for the default
256
     * @return the ref-scoped top-level view displays
257
     */
258
    public List<ViewDisplay> getTopLevelViewDisplays(String refRoot) {
259
        if (refRoot == null || refRoot.isEmpty()) return getTopLevelViewDisplays();
×
260
        return filterViewDisplays(buildViewDisplays(viewDisplaysRefQueryRef(refRoot)), true, getId(), getOwnClasses());
×
261
    }
262

263
    private List<ViewDisplay> filterViewDisplays(List<ViewDisplay> source, boolean toplevel, String resourceId, Set<IRI> classes) {
264
        List<ViewDisplay> viewDisplays = new ArrayList<>();
×
265
        Set<IRI> viewKinds = new HashSet<>();
×
266

267
        // Results are sorted by date (most recent first); only the most recent per view-kind is considered
268
        for (ViewDisplay vd : source) {
×
269
            IRI kind = vd.getViewKindIri();
×
270
            if (kind != null) {
×
271
                if (viewKinds.contains(kind)) {
×
272
                    continue;
×
273
                }
274
                viewKinds.add(kind);
×
275
            }
276

277
            if (vd.hasType(KPXL_TERMS.DEACTIVATED_VIEW_DISPLAY)) {
×
278
                continue;
×
279
            }
280

281
            if (!toplevel && vd.hasType(KPXL_TERMS.TOP_LEVEL_VIEW_DISPLAY)) {
×
282
                // Deprecated
283
                // do nothing
284
            } else if (vd.appliesTo(resourceId, classes)) {
×
285
                viewDisplays.add(vd);
×
286
            } else if (toplevel && vd.hasType(KPXL_TERMS.TOP_LEVEL_VIEW_DISPLAY)) {
×
287
                // Deprecated
288
                viewDisplays.add(vd);
×
289
            }
290
        }
×
291

292
        Collections.sort(viewDisplays);
×
293
        return viewDisplays;
×
294
    }
295

296
    /**
297
     * Builds {@link ViewDisplay} objects from a get-view-displays(-ref) query result (standalone
298
     * displays with a bound {@code ?display}, and preset-supplied views with an unbound one).
299
     */
300
    private List<ViewDisplay> buildViewDisplays(QueryRef ref) {
301
        List<ViewDisplay> list = new ArrayList<>();
×
302
        ApiResponse response = ApiCache.retrieveResponseSync(ref, true);
×
303
        // Null on a cold cache or a failed (flaky federated) fetch — yield nothing for now; the
304
        // cache refreshes asynchronously and the page's auto-refresh repopulates it.
305
        if (response == null) return list;
×
306
        for (ApiResponseEntry r : response.getData()) {
×
307
            try {
308
                String display = r.get("display");
×
309
                if (display != null && !display.isEmpty()) {
×
310
                    // The query resolves ?view to its latest version server-side, so
311
                    // pass it through to avoid a separate per-view latest-version lookup.
312
                    list.add(ViewDisplay.get(display, r.get("view")));
×
313
                } else {
314
                    String view = r.get("view");
×
315
                    if (view == null || view.isEmpty()) continue;
×
316
                    boolean topLevel = KPXL_TERMS.TOP_LEVEL_VIEW_DISPLAY.stringValue().equals(r.get("displayType"));
×
317
                    boolean deactivated = KPXL_TERMS.DEACTIVATED_PRESET_ASSIGNMENT.stringValue().equals(r.get("displayMode"));
×
318
                    ViewDisplay vd = ViewDisplay.forPresetView(id, view, topLevel, deactivated);
×
319
                    if (vd != null) list.add(vd);
×
320
                }
321
            } catch (IllegalArgumentException ex) {
×
322
                logger.error("Couldn't generate view display object", ex);
×
323
            }
×
324
        }
×
325
        return list;
×
326
    }
327

328
    private QueryRef viewDisplaysRefQueryRef(String refRoot) {
329
        Multimap<String, String> params = ArrayListMultimap.create();
×
330
        params.put("resource", id);
×
331
        params.put("root_np", refRoot);
×
332
        return new QueryRef(QueryApiAccess.GET_VIEW_DISPLAYS_REF, params);
×
333
    }
334

335
    /**
336
     * The root nanopub of the space ref the resource's view displays should be scoped to, or null
337
     * to use the IRI-keyed query (merged across refs). Null by default; {@link Space} overrides it
338
     * to its representative ref so a multi-ref identifier doesn't merge Content-tab displays.
339
     *
340
     * @return the ref's root nanopub, or null
341
     */
342
    protected String getViewDisplayRefRoot() {
343
        return null;
×
344
    }
345

346
    @Override
347
    public abstract String getLabel();
348

349
    @Override
350
    public String toString() {
351
        return id;
×
352
    }
353

354
    /**
355
     * Gets the chain of superspaces from the current space up to the root space.
356
     *
357
     * @return the list of superspaces from the given space to the root space
358
     */
359
    @Override
360
    public List<AbstractResourceWithProfile> getAllSuperSpacesUntilRoot() {
361
        List<AbstractResourceWithProfile> chain = new ArrayList<>();
×
362
        Set<String> visited = new HashSet<>();
×
363
        collectAncestors(space, chain, visited);
×
364
        Collections.reverse(chain);
×
365
        return chain;
×
366
    }
367

368
    private void collectAncestors(Space current, List<AbstractResourceWithProfile> chain, Set<String> visited) {
369
        if (current == null) {
×
370
            return;
×
371
        }
372
        List<Space> parents = SpaceRepository.get().findSuperspaces(current);
×
373
        if (parents == null || parents.isEmpty()) {
×
374
            return;
×
375
        }
376
        Space parent = parents.getFirst();
×
377
        if (parent == null) {
×
378
            return;
×
379
        }
380
        String pid = parent.getId();
×
381
        if (pid == null || !visited.add(pid)) {
×
382
            return;
×
383
        }
384
        chain.add(parent);
×
385
        collectAncestors(parent, chain, visited);
×
386
    }
×
387

388
    /**
389
     * Checks if any view display of this resource applies to the given element ID and set of classes by triggering a data update and checking each view display for applicability.
390
     *
391
     * @param elementId the ID of the element to check for applicability
392
     * @param classes   the set of classes to check for applicability
393
     * @return true if any view display of this resource applies to the given element ID and set of classes, false otherwise
394
     */
395
    public boolean appliesTo(String elementId, Set<IRI> classes) {
396
        triggerDataUpdate();
×
397
        for (ViewDisplay v : getViewDisplays()) {
×
398
            if (v.appliesTo(elementId, classes)) {
×
399
                return true;
×
400
            }
401
        }
×
402
        return false;
×
403
    }
404

405
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc