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

knowledgepixels / nanodash / 27622721129

16 Jun 2026 01:55PM UTC coverage: 26.963% (+6.3%) from 20.697%
27622721129

Pull #483

github

web-flow
Merge 73a4d0fe1 into 663f14f46
Pull Request #483: Space/resource About pages, ref-aware spaces, and magic query params

1542 of 6717 branches covered (22.96%)

Branch coverage included in aggregate %.

3407 of 11638 relevant lines covered (29.27%)

4.31 hits per line

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

75.97
src/main/java/com/knowledgepixels/nanodash/repository/SpaceRepository.java
1
package com.knowledgepixels.nanodash.repository;
2

3
import com.github.jsonldjava.shaded.com.google.common.collect.Ordering;
4
import com.knowledgepixels.nanodash.ApiCache;
5
import com.knowledgepixels.nanodash.QueryApiAccess;
6
import com.knowledgepixels.nanodash.domain.Space;
7
import com.knowledgepixels.nanodash.domain.SpaceFactory;
8
import org.nanopub.extra.services.ApiResponse;
9
import org.nanopub.extra.services.ApiResponseEntry;
10
import org.nanopub.extra.services.QueryRef;
11
import org.slf4j.Logger;
12
import org.slf4j.LoggerFactory;
13

14
import java.util.ArrayList;
15
import java.util.Collections;
16
import java.util.HashMap;
17
import java.util.HashSet;
18
import java.util.LinkedHashMap;
19
import java.util.LinkedHashSet;
20
import java.util.List;
21
import java.util.Map;
22
import java.util.Set;
23

24
/**
25
 * Repository class for managing Space instances, providing methods to refresh, retrieve, and query spaces based on API responses.
26
 */
27
public class SpaceRepository {
28

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

31
    private static final SpaceRepository INSTANCE = new SpaceRepository();
15✔
32

33
    /**
34
     * Get the singleton instance of SpaceRepository.
35
     *
36
     * @return The singleton instance of SpaceRepository.
37
     */
38
    public static SpaceRepository get() {
39
        return INSTANCE;
6✔
40
    }
41

42
    private SpaceRepository() {
6✔
43
    }
3✔
44

45
    private static final class Snapshot {
46
        final Map<String, Space> spacesById;
47
        final Map<String, Space> spacesByAltId;
48
        final Map<Space, Set<Space>> subspaceMap;
49
        final Map<Space, Set<Space>> superspaceMap;
50

51
        Snapshot(Map<String, Space> byId,
52
                 Map<String, Space> byAltId,
53
                 Map<Space, Set<Space>> subspaces,
54
                 Map<Space, Set<Space>> superspaces) {
6✔
55
            this.spacesById = byId;
9✔
56
            this.spacesByAltId = byAltId;
9✔
57
            this.subspaceMap = subspaces;
9✔
58
            this.superspaceMap = superspaces;
9✔
59
        }
3✔
60

61
        static final Snapshot EMPTY = new Snapshot(
9✔
62
                Collections.emptyMap(), Collections.emptyMap(),
6✔
63
                Collections.emptyMap(), Collections.emptyMap());
12✔
64
    }
65

66
    // Source of truth is ApiCache (60s TTL on its own); we memoise the derived
67
    // maps keyed by the ApiResponse instance identity for get-spaces, and
68
    // re-resolve the sub-space link response on each rebuild.
69
    private volatile ApiResponse cachedFor;
70
    private volatile Snapshot snapshot = Snapshot.EMPTY;
9✔
71

72
    private Snapshot current() {
73
        ApiResponse resp = ApiCache.retrieveResponseSync(new QueryRef(QueryApiAccess.GET_SPACES_REF), false);
21✔
74
        if (resp == null) {
6!
75
            return snapshot;
×
76
        }
77
        if (resp == cachedFor) {
12✔
78
            return snapshot;
9✔
79
        }
80
        synchronized (this) {
12✔
81
            if (resp == cachedFor) {
12!
82
                return snapshot;
×
83
            }
84
            Snapshot built = build(resp);
12✔
85
            snapshot = built;
9✔
86
            cachedFor = resp;
9✔
87
            return built;
12✔
88
        }
89
    }
90

91
    /**
92
     * Reduce the {@code get-spaces} rows to one representative entry per space IRI and
93
     * collect the distinct ref roots seen for each IRI. Rows are expected in
94
     * {@code DESC(?date)} order.
95
     * <p>
96
     * The ref-aware query (v3) returns {@code ?ref} (the space ref = space IRI + root
97
     * definition) and {@code ?root} (the ref's root nanopub) per (ref, definition) row.
98
     * We dedup by ref (latest definition within a ref wins), then keep one representative
99
     * per IRI — the globally latest definition, preserving the pre-ref-aware behaviour —
100
     * and return the full set of ref roots per IRI for the (deferred) multi-ref
101
     * disambiguation UI. See docs/space-ref-identity.md.
102
     * <p>
103
     * When rows carry no {@code ?ref} (the pre-v3 {@code GET_SPACES} query, e.g. a server
104
     * that has not ingested v3) this degrades to dedup-by-IRI exactly as before, with
105
     * empty ref-root sets.
106
     */
107
    static RefReduction reduceByRef(List<ApiResponseEntry> rows) {
108
        Set<String> seenRef = new HashSet<>();
12✔
109
        Map<String, Set<String>> refRootsByIri = new LinkedHashMap<>();
12✔
110
        Map<String, ApiResponseEntry> representativeByIri = new LinkedHashMap<>();
12✔
111
        for (ApiResponseEntry r : rows) {
30✔
112
            String spaceIri = r.get("space_iri");
12✔
113
            if (spaceIri == null || spaceIri.isEmpty()) continue;
18!
114
            String ref = r.get("ref");
12✔
115
            // Dedup by ref when available, else by IRI (pre-v3 query).
116
            String refKey = (ref != null && !ref.isEmpty()) ? ref : spaceIri;
27!
117
            if (!seenRef.add(refKey)) continue;
15✔
118
            String root = r.get("root");
12✔
119
            if (root != null && !root.isEmpty()) {
15!
120
                refRootsByIri.computeIfAbsent(spaceIri, k -> new LinkedHashSet<>()).add(root);
36✔
121
            }
122
            // First surviving row per IRI = latest definition overall (rows are DESC(date)).
123
            representativeByIri.putIfAbsent(spaceIri, r);
15✔
124
        }
3✔
125
        return new RefReduction(new ArrayList<>(representativeByIri.values()), refRootsByIri);
30✔
126
    }
127

128
    /** Result of {@link #reduceByRef}: one representative row per IRI plus the ref roots per IRI. */
129
    static final class RefReduction {
130
        final List<ApiResponseEntry> representatives;
131
        final Map<String, Set<String>> refRootsByIri;
132

133
        RefReduction(List<ApiResponseEntry> representatives, Map<String, Set<String>> refRootsByIri) {
6✔
134
            this.representatives = representatives;
9✔
135
            this.refRootsByIri = refRootsByIri;
9✔
136
        }
3✔
137
    }
138

139
    /**
140
     * Build the space lookup maps from the spaces-repo response. Pulls the
141
     * latest non-invalidated SpaceDefinition per space ref from
142
     * {@code npa:spacesGraph} and joins to the declaring nanopub for label and
143
     * type, then keeps one representative space per IRI (see {@link #reduceByRef}).
144
     * Each space additionally carries the set of ref roots claiming its IRI for the
145
     * (deferred) multi-ref disambiguation UI.
146
     */
147
    private Snapshot build(ApiResponse resp) {
148
        Map<String, Space> byId = new HashMap<>();
12✔
149
        Map<String, Space> byAltId = new HashMap<>();
12✔
150
        Map<Space, Set<Space>> subspaceMap = new HashMap<>();
12✔
151
        Map<Space, Set<Space>> superspaceMap = new HashMap<>();
12✔
152
        List<Space> spaceList = new ArrayList<>();
12✔
153
        RefReduction reduction = reduceByRef(resp.getData());
12✔
154
        for (ApiResponseEntry r : reduction.representatives) {
33✔
155
            String spaceIri = r.get("space_iri");
12✔
156
            ApiResponseEntry entry = new ApiResponseEntry();
12✔
157
            entry.add("space", spaceIri);
12✔
158
            entry.add("np", r.get("np"));
18✔
159
            entry.add("label", r.get("space_iri_label"));
18✔
160
            entry.add("type", r.get("type"));
18✔
161
            String refRoot = r.get("root");
12✔
162
            if (refRoot != null && !refRoot.isEmpty()) entry.add("ref_root", refRoot);
27!
163
            Space space = SpaceFactory.getOrCreate(entry);
9✔
164
            space.setRefRoots(reduction.refRootsByIri.getOrDefault(spaceIri, Collections.emptySet()));
24✔
165
            spaceList.add(space);
12✔
166
            byId.put(space.getId(), space);
18✔
167
            for (String altId : space.getAltIDs()) {
33✔
168
                byAltId.put(altId, space);
15✔
169
            }
3✔
170
        }
3✔
171
        logger.info("Refreshed spaces from spaces repo: {} distinct spaces", spaceList.size());
18✔
172
        SpaceFactory.removeStale(byId.keySet());
9✔
173
        populateSubspaceRelations(byId, subspaceMap, superspaceMap);
12✔
174
        // Mark each space's per-space detail data stale; the upstream spaces
175
        // listing has refreshed, so members/admins/roles should be re-fetched
176
        // on next access.
177
        for (Space space : spaceList) {
30✔
178
            space.setDataNeedsUpdate();
6✔
179
        }
3✔
180
        return new Snapshot(byId, byAltId, subspaceMap, superspaceMap);
24✔
181
    }
182

183
    /**
184
     * Invalidate the underlying ApiCache entries, optionally delaying the next refresh.
185
     *
186
     * @param waitMillis Delay in milliseconds before the next access may trigger a refresh; 0 for immediate.
187
     */
188
    public void forceRootRefresh(long waitMillis) {
189
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACES_REF), waitMillis);
×
190
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SUB_SPACE_LINKS), waitMillis);
×
191
    }
×
192

193
    /**
194
     * Get a space by its id.
195
     *
196
     * @param id The id of the space.
197
     * @return The corresponding Space object, or null if not found.
198
     */
199
    public Space findById(String id) {
200
        return current().spacesById.get(id);
21✔
201
    }
202

203
    /**
204
     * Get a space by one of its alternative IDs.
205
     *
206
     * @param altId The alternative ID of the space.
207
     * @return The corresponding Space object, or null if not found.
208
     */
209
    public Space findByAltId(String altId) {
210
        return current().spacesByAltId.get(altId);
21✔
211
    }
212

213
    /**
214
     * Get subspaces of a given space.
215
     *
216
     * @param space The space for which to find subspaces.
217
     * @return List of subspaces.
218
     */
219
    public List<Space> findSubspaces(Space space) {
220
        Set<Space> subspaces = current().subspaceMap.get(space);
×
221
        if (subspaces == null) return new ArrayList<>();
×
222
        List<Space> sorted = new ArrayList<>(subspaces);
×
223
        sorted.sort(Ordering.usingToString());
×
224
        return sorted;
×
225
    }
226

227
    /**
228
     * Get subspaces of a given space that match a specific type.
229
     *
230
     * @param space The space for which to find subspaces.
231
     * @param type  The type of subspaces to filter by.
232
     * @return List of subspaces matching the specified type.
233
     */
234
    public List<Space> findSubspaces(Space space, String type) {
235
        List<Space> l = new ArrayList<>();
×
236
        for (Space s : findSubspaces(space)) {
×
237
            if (s.getType().equals(type)) l.add(s);
×
238
        }
×
239
        return l;
×
240
    }
241

242
    /**
243
     * Get superspaces of this space.
244
     *
245
     * @return List of superspaces.
246
     */
247
    public List<Space> findSuperspaces(Space space) {
248
        Set<Space> superspaces = current().superspaceMap.get(space);
×
249
        if (superspaces == null) return new ArrayList<>();
×
250
        List<Space> sorted = new ArrayList<>(superspaces);
×
251
        sorted.sort(Ordering.usingToString());
×
252
        return sorted;
×
253
    }
254

255
    private static void populateSubspaceRelations(
256
            Map<String, Space> spacesById,
257
            Map<Space, Set<Space>> subspaceMap,
258
            Map<Space, Set<Space>> superspaceMap) {
259
        ApiResponse resp = ApiCache.retrieveResponseSync(new QueryRef(QueryApiAccess.GET_SUB_SPACE_LINKS), false);
21✔
260
        if (resp == null) return;
6!
261
        for (ApiResponseEntry r : resp.getData()) {
33✔
262
            Space child = spacesById.get(r.get("child"));
21✔
263
            Space parent = spacesById.get(r.get("parent"));
21✔
264
            if (child == null || parent == null) continue;
12!
265
            subspaceMap.computeIfAbsent(parent, k -> new HashSet<>()).add(child);
36✔
266
            superspaceMap.computeIfAbsent(child, k -> new HashSet<>()).add(parent);
36✔
267
        }
3✔
268
    }
3✔
269

270
}
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