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

knowledgepixels / nanopub-query / 26118107010

19 May 2026 06:47PM UTC coverage: 58.355% (+0.1%) from 58.251%
26118107010

push

github

web-flow
Merge pull request #102 from knowledgepixels/fix/invalidation-join-load-order-race

fix(AuthorityResolver): join raw npx:invalidates so materialiser is symmetric in load order

480 of 900 branches covered (53.33%)

Branch coverage included in aggregate %.

1301 of 2152 relevant lines covered (60.46%)

9.33 hits per line

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

89.41
src/main/java/com/knowledgepixels/query/SpacesExtractor.java
1
package com.knowledgepixels.query;
2

3
import java.util.ArrayList;
4
import java.util.Collection;
5
import java.util.Collections;
6
import java.util.Date;
7
import java.util.LinkedHashSet;
8
import java.util.List;
9
import java.util.Set;
10

11
import org.eclipse.rdf4j.model.IRI;
12
import org.eclipse.rdf4j.model.Literal;
13
import org.eclipse.rdf4j.model.Resource;
14
import org.eclipse.rdf4j.model.Statement;
15
import org.eclipse.rdf4j.model.ValueFactory;
16
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
17
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
18
import org.eclipse.rdf4j.model.vocabulary.RDF;
19
import org.nanopub.Nanopub;
20
import org.nanopub.NanopubUtils;
21
import org.nanopub.vocabulary.NPA;
22
import org.nanopub.vocabulary.NPX;
23
import org.slf4j.Logger;
24
import org.slf4j.LoggerFactory;
25

26
import com.knowledgepixels.query.vocabulary.BackcompatRolePredicates;
27
import com.knowledgepixels.query.vocabulary.GEN;
28
import com.knowledgepixels.query.vocabulary.SpacesVocab;
29

30
import net.trustyuri.TrustyUriUtils;
31

32
/**
33
 * Pure-logic extractor from a loaded {@link Nanopub} to the add-only summary
34
 * triples destined for {@code npa:spacesGraph}. Implements the per-type schema
35
 * from {@code doc/design-space-repositories.md}.
36
 *
37
 * <p>Dispatch is by nanopub type — {@link NanopubUtils#getTypes(Nanopub)} returns
38
 * both {@code rdf:type} / {@code npx:hasNanopubType} declarations and, for
39
 * single-predicate-assertion nanopubs, the predicate itself. That means the
40
 * four predefined types ({@link GEN#SPACE}, {@link GEN#HAS_ROLE},
41
 * {@link GEN#SPACE_MEMBER_ROLE}, {@link GEN#ROLE_INSTANTIATION}) and all 14
42
 * {@link BackcompatRolePredicates backwards-compat predicates} can be detected
43
 * with a single type-set lookup.
44
 *
45
 * <p>Output: a list of RDF4J {@link Statement}s, all in the
46
 * {@link SpacesVocab#SPACES_GRAPH} named graph, that the caller writes into the
47
 * {@code spaces} repo. Deterministic and idempotent — the same nanopub always
48
 * produces the same statement set.
49
 */
50
public final class SpacesExtractor {
51

52
    private static final Logger log = LoggerFactory.getLogger(SpacesExtractor.class);
9✔
53

54
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
55

56
    private static final IRI GRAPH = SpacesVocab.SPACES_GRAPH;
9✔
57

58
    private SpacesExtractor() {
59
    }
60

61
    /**
62
     * Bundles the information a single extraction needs beyond the nanopub itself.
63
     *
64
     * @param artifactCode trusty-URI artifact code of {@code np} (used for minting
65
     *                     {@code npari:}/{@code npara:}/{@code npard:}/{@code npadef:}
66
     *                     subject IRIs).
67
     * @param signedBy     signer agent IRI from pubinfo, or {@code null} if absent.
68
     * @param pubkeyHash   hash of the signing public key, or {@code null} if absent.
69
     * @param createdAt    creation timestamp, or {@code null} if the nanopub lacks one.
70
     */
71
    public record Context(String artifactCode, IRI signedBy, String pubkeyHash, Date createdAt) {
45✔
72
    }
73

74
    /**
75
     * Runs the extractor on a loaded nanopub. Returns an empty list if the nanopub is
76
     * not space-relevant.
77
     *
78
     * @param np  the nanopub to inspect
79
     * @param ctx the extraction context
80
     * @return statements to write into {@code npa:spacesGraph}
81
     */
82
    public static List<Statement> extract(Nanopub np, Context ctx) {
83
        Set<IRI> types = NanopubUtils.getTypes(np);
9✔
84
        List<Statement> out = new ArrayList<>();
12✔
85

86
        boolean isSpace = types.contains(GEN.SPACE);
12✔
87
        boolean isHasRole = types.contains(GEN.HAS_ROLE);
12✔
88
        boolean isSpaceMemberRole = types.contains(GEN.SPACE_MEMBER_ROLE);
12✔
89
        boolean isRoleInstantiation = types.contains(GEN.ROLE_INSTANTIATION)
18!
90
                || anyMatch(types, BackcompatRolePredicates.ALL);
18✔
91
        boolean isSubSpaceOf = types.contains(GEN.IS_SUB_SPACE_OF);
12✔
92
        // Maintained-resource nanopubs use either the resource-class marker
93
        // (gen:MaintainedResource — what Nanodash currently writes) or the
94
        // predicate marker (gen:isMaintainedBy — single-predicate-assertion
95
        // auto-typing or explicit npx:hasNanopubType). Both shapes carry the
96
        // same <r> gen:isMaintainedBy <s> triple in the assertion.
97
        boolean isMaintainedResource = types.contains(GEN.MAINTAINED_RESOURCE)
18✔
98
                || types.contains(GEN.IS_MAINTAINED_BY);
18✔
99

100
        if (!isSpace && !isHasRole && !isSpaceMemberRole && !isRoleInstantiation
36✔
101
                && !isSubSpaceOf && !isMaintainedResource) {
102
            return Collections.emptyList();
6✔
103
        }
104

105
        if (isSpace) extractSpace(np, ctx, out);
18✔
106
        if (isHasRole) extractHasRole(np, ctx, out);
18✔
107
        if (isSpaceMemberRole) extractSpaceMemberRole(np, ctx, out);
18✔
108
        if (isRoleInstantiation) extractRoleInstantiation(np, ctx, out);
18✔
109
        if (isSubSpaceOf) extractSubSpaceOf(np, ctx, out);
18✔
110
        if (isMaintainedResource) extractIsMaintainedBy(np, ctx, out);
18✔
111

112
        return out;
6✔
113
    }
114

115
    // ---------------- gen:Space ----------------
116

117
    private static void extractSpace(Nanopub np, Context ctx, List<Statement> out) {
118
        // A single gen:Space nanopub may declare multiple Space IRIs, each via its own
119
        // gen:hasRootDefinition triple. We emit one SpaceRef + SpaceDefinition per
120
        // Space IRI. A nanopub missing any hasRootDefinition is accepted as its own
121
        // root for every Space IRI it declares (transition backcompat).
122
        Set<IRI> handled = new LinkedHashSet<>();
12✔
123
        List<IRI> adminAgents = collectAdminAgents(np);
9✔
124

125
        // Rooted case: gen:hasRootDefinition explicitly declared.
126
        for (Statement st : np.getAssertion()) {
33✔
127
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) continue;
18✔
128
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
129
            if (!(st.getObject() instanceof IRI rootUri)) continue;
27!
130
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12✔
131
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
132
                log.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}",
×
133
                        spaceIri, rootUri);
134
                continue;
×
135
            }
136
            if (!handled.add(spaceIri)) continue;
12!
137
            emitSpaceEntry(np, ctx, spaceIri, rootUri, rootNanopubId, adminAgents, out);
24✔
138
        }
3✔
139

140
        // Rootless transition case: any Space IRI in the assertion that didn't get a
141
        // hasRootDefinition triple is treated as if it were its own root. Detect by
142
        // looking for triples that reference a Space IRI we haven't handled yet —
143
        // typically via gen:hasAdmin subjects or the rdf:type gen:Space triple on a
144
        // blank-node assertion subject. The common template publishes the Space IRI
145
        // as the subject of at least one triple in the assertion, so we scan for that.
146
        for (Statement st : np.getAssertion()) {
33✔
147
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
148
            if (handled.contains(spaceIri)) continue;
15✔
149
            // Skip IRIs that clearly aren't Space IRIs (role IRIs embedded in this nanopub).
150
            if (spaceIri.stringValue().startsWith(np.getUri().stringValue())) continue;
21!
151
            // Require at least one structural signal that this is a Space IRI:
152
            // an rdf:type gen:Space, or a gen:hasAdmin triple with this as subject.
153
            if (!looksLikeSpaceIri(np, spaceIri)) continue;
15✔
154
            handled.add(spaceIri);
12✔
155
            String rootNanopubId = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
156
            if (rootNanopubId == null || rootNanopubId.isEmpty()) continue;
15!
157
            emitSpaceEntry(np, ctx, spaceIri, np.getUri(), rootNanopubId, adminAgents, out);
27✔
158
        }
3✔
159
    }
3✔
160

161
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
162
                                       String rootNanopubId, List<IRI> adminAgents,
163
                                       List<Statement> out) {
164
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15✔
165
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9✔
166
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12✔
167

168
        // Aggregate entry: contributor-independent, reinforced on every contribution.
169
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27✔
170
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27✔
171
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27✔
172

173
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
174
        // fallback in the materializer. Same triples on every contributor (RDF set
175
        // semantics dedups them).
176
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33✔
177
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27✔
178
        }
3✔
179

180
        // Embedded gen:isSubSpaceOf triples in this gen:Space nanopub: emit one
181
        // SubSpaceDeclaration per (spaceIri, parentIri) pair. Same shape as the
182
        // standalone path; downstream rules don't distinguish them.
183
        emitSubSpaceDeclarations(np, ctx, spaceIri, out);
15✔
184

185
        // Embedded gen:isMaintainedBy triples in this gen:Space nanopub: emit one
186
        // MaintainedResourceDeclaration per (resourceIri, spaceIri) pair where the
187
        // object equals the Space being defined. Same shape as the standalone path.
188
        emitMaintainedResourceDeclarations(np, ctx, spaceIri, out);
15✔
189

190
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
191
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
192
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
193
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
194
        addProvenance(defIri, ctx, out);
12✔
195

196
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
197
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
198
        if (isOwnRoot) {
6✔
199
            for (IRI adminAgent : adminAgents) {
30✔
200
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
201
            }
3✔
202
        }
203

204
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
205
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
206
        if (!adminAgents.isEmpty()) {
9!
207
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
208
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
209
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
210
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27✔
211
            for (IRI adminAgent : adminAgents) {
30✔
212
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27✔
213
            }
3✔
214
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
215
            addProvenance(riIri, ctx, out);
12✔
216
        }
217
    }
3✔
218

219
    /**
220
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
221
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
222
     * assertion contains {@code candidate rdf:type gen:Space} or
223
     * {@code candidate gen:hasAdmin ?x}.
224
     */
225
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
226
        for (Statement st : np.getAssertion()) {
33✔
227
            if (!candidate.equals(st.getSubject())) continue;
18✔
228
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) return true;
36!
229
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) return true;
15!
230
        }
3✔
231
        return false;
6✔
232
    }
233

234
    private static List<IRI> collectAdminAgents(Nanopub np) {
235
        Set<IRI> agents = new LinkedHashSet<>();
12✔
236
        for (Statement st : np.getAssertion()) {
33✔
237
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) continue;
18✔
238
            if (!(st.getObject() instanceof IRI agent)) continue;
27!
239
            agents.add(agent);
12✔
240
        }
3✔
241
        return new ArrayList<>(agents);
15✔
242
    }
243

244
    // ---------------- gen:hasRole (role attachment) ----------------
245

246
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
247
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
248
        for (Statement st : np.getAssertion()) {
33!
249
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) continue;
15!
250
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
251
            if (!(st.getObject() instanceof IRI roleIri)) continue;
27!
252
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12✔
253
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27✔
254
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
255
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27✔
256
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
257
            addProvenance(subject, ctx, out);
12✔
258
            // One attachment per nanopub — the subject IRI is derived from the nanopub
259
            // artifact code so multiple hasRole triples in the same nanopub would collide.
260
            // If that case shows up in practice, we'll refine the subject-minting scheme.
261
            return;
3✔
262
        }
263
    }
×
264

265
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
266

267
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
268
        // The role IRI is embedded in this nanopub, so look for an assertion statement
269
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
270
        // with the nanopub IRI (valid embedded mint).
271
        IRI roleIri = null;
6✔
272
        for (Statement st : np.getAssertion()) {
33✔
273
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
274
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) continue;
18✔
275
            if (!(st.getSubject() instanceof IRI candidate)) continue;
27!
276
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) continue;
24✔
277
            roleIri = candidate;
6✔
278
            break;
3✔
279
        }
280
        if (roleIri == null) return;
9✔
281

282
        IRI roleType = findRoleTier(np, roleIri);
12✔
283
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
284
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
285

286
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12✔
287
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27✔
288
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27✔
289
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27✔
290
        for (IRI reg : regulars) {
30✔
291
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27✔
292
        }
3✔
293
        for (IRI inv : inverses) {
30✔
294
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27✔
295
        }
3✔
296
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
297
        if (ctx.createdAt() != null) {
9!
298
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36✔
299
        }
300
    }
3✔
301

302
    /**
303
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
304
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
305
     * {@code gen:ObserverRole} if none is declared.
306
     */
307
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
308
        for (Statement st : np.getAssertion()) {
33✔
309
            if (!roleIri.equals(st.getSubject())) continue;
15!
310
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
311
            if (!(st.getObject() instanceof IRI type)) continue;
27!
312
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30!
313
                    || GEN.OBSERVER_ROLE.equals(type)) {
6!
314
                return type;
6✔
315
            }
316
        }
3✔
317
        return GEN.OBSERVER_ROLE;
6✔
318
    }
319

320
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
321
        List<IRI> out = new ArrayList<>();
12✔
322
        for (Statement st : np.getAssertion()) {
33✔
323
            if (!roleIri.equals(st.getSubject())) continue;
15!
324
            if (!predicate.equals(st.getPredicate())) continue;
18✔
325
            if (!(st.getObject() instanceof IRI obj)) continue;
27!
326
            out.add(obj);
12✔
327
        }
3✔
328
        return out;
6✔
329
    }
330

331
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
332

333
    private static void extractRoleInstantiation(Nanopub np, Context ctx, List<Statement> out) {
334
        // Find the assignment triple. Directionality (matches the publisher convention
335
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
336
        // nanopubs):
337
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
338
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
339
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
340
        // The 14 backwards-compat predicates are classified in
341
        // {@link BackcompatRolePredicates#DIRECTIONS}. User-defined role predicates from
342
        // gen:SpaceMemberRole nanopubs aren't resolvable here without the role-declaration
343
        // registry; FIXME: the materializer in PR 2 should refine direction for the
344
        // typed-but-unknown-predicate case. For now we emit only triples whose predicate
345
        // we know the direction of.
346
        for (Statement st : np.getAssertion()) {
33!
347
            IRI predicate = st.getPredicate();
9✔
348
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9✔
349
            if (direction == null) continue;
6!
350
            if (!(st.getSubject() instanceof IRI subjIri)) continue;
27!
351
            if (!(st.getObject() instanceof IRI objIri)) continue;
27!
352

353
            IRI spaceSide;
354
            IRI agentSide;
355
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
356
                agentSide = subjIri;
6✔
357
                spaceSide = objIri;
9✔
358
            } else {
359
                spaceSide = subjIri;
6✔
360
                agentSide = objIri;
6✔
361
            }
362

363
            // Deduplicate against the (possibly already emitted) admin instantiation
364
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
365
            // have a gen:hasAdmin triple that the backcompat list also catches. The
366
            // subject IRI is the same (derived from artifact code) and the payload
367
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
368
            // entry on this subject.
369
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
370
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21✔
371
            if (out.contains(typeSt)) return;
12!
372

373
            out.add(typeSt);
12✔
374
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
375
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
376
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
377
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
378
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
379
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
380
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
381
            addProvenance(subject, ctx, out);
12✔
382
            return;
3✔
383
        }
384
    }
×
385

386
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
387
        if (GEN.HAS_ADMIN.equals(predicate)) return BackcompatRolePredicates.Direction.INVERSE;
12!
388
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
389
    }
390

391
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
392

393
    /**
394
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
395
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
396
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
397
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
398
     */
399
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
400
        for (Statement st : np.getAssertion()) {
33✔
401
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
15!
402
            if (!(st.getSubject() instanceof IRI childIri)) continue;
27!
403
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
404
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18✔
405
        }
3✔
406
    }
3✔
407

408
    /**
409
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
410
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
411
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
412
     * this particular Space). Self-loops are rejected.
413
     */
414
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
415
                                                 List<Statement> out) {
416
        for (Statement st : np.getAssertion()) {
33✔
417
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
18✔
418
            if (!spaceIri.equals(st.getSubject())) continue;
18✔
419
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
420
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18✔
421
        }
3✔
422
    }
3✔
423

424
    /**
425
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
426
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
427
     * parents without subject collision. Self-loops are silently dropped.
428
     */
429
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
430
                                                IRI parentIri, List<Statement> out) {
431
        if (childIri.equals(parentIri)) {
12✔
432
            log.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18✔
433
            return;
3✔
434
        }
435
        String parentHash = Utils.createHash(parentIri);
9✔
436
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15✔
437

438
        // Idempotence: the embedded and standalone paths can both fire on the same
439
        // (np, child, parent) combination if a gen:Space nanopub somehow ends up typed
440
        // gen:isSubSpaceOf as well. Skip if we've already emitted the type triple for
441
        // this subject.
442
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SUB_SPACE_DECLARATION, GRAPH);
21✔
443
        if (out.contains(typeSt)) return;
12!
444

445
        out.add(typeSt);
12✔
446
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
447
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
448
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
449
        addProvenance(subject, ctx, out);
12✔
450
    }
3✔
451

452
    // ---------------- gen:isMaintainedBy ----------------
453

454
    /**
455
     * Standalone {@code gen:isMaintainedBy} nanopub: every
456
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triple in the assertion emits
457
     * one {@code npa:MaintainedResourceDeclaration}. Multi-triple assertions are
458
     * allowed; one entry per pair. Self-loops ({@code <X> gen:isMaintainedBy <X>}) are
459
     * rejected.
460
     */
461
    private static void extractIsMaintainedBy(Nanopub np, Context ctx, List<Statement> out) {
462
        for (Statement st : np.getAssertion()) {
33✔
463
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) continue;
18✔
464
            if (!(st.getSubject() instanceof IRI resourceIri)) continue;
27!
465
            if (!(st.getObject() instanceof IRI spaceIri)) continue;
27!
466
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
467
        }
3✔
468
    }
3✔
469

470
    /**
471
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
472
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triples (object must equal
473
     * the Space IRI we're emitting an entry for, so the maintained-resource
474
     * declaration is bound to this particular Space). Self-loops are rejected.
475
     */
476
    private static void emitMaintainedResourceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
477
                                                           List<Statement> out) {
478
        for (Statement st : np.getAssertion()) {
33✔
479
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) continue;
18✔
480
            if (!spaceIri.equals(st.getObject())) continue;
18✔
481
            if (!(st.getSubject() instanceof IRI resourceIri)) continue;
27!
482
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
483
        }
3✔
484
    }
3✔
485

486
    /**
487
     * Emits one {@code npa:MaintainedResourceDeclaration} entry, keyed by
488
     * {@code (artifactCode, resourceHash)} so a single nanopub can declare multiple
489
     * maintained resources without subject collision. Self-loops are silently dropped.
490
     */
491
    private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, IRI resourceIri,
492
                                                          IRI spaceIri, List<Statement> out) {
493
        if (resourceIri.equals(spaceIri)) {
12✔
494
            log.debug("Ignoring self-loop maintained-resource declaration on {} in {}",
15✔
495
                    resourceIri, np.getUri());
3✔
496
            return;
3✔
497
        }
498
        String resourceHash = Utils.createHash(resourceIri);
9✔
499
        IRI subject = SpacesVocab.forMaintainedResourceDeclaration(ctx.artifactCode(), resourceHash);
15✔
500

501
        // Idempotence: the embedded (gen:Space) and standalone (gen:isMaintainedBy)
502
        // paths can both fire on the same (np, resource, space) combination if a
503
        // gen:Space nanopub somehow ends up typed gen:isMaintainedBy as well. Skip if
504
        // we've already emitted the type triple for this subject.
505
        Statement typeSt = vf.createStatement(subject, RDF.TYPE,
21✔
506
                SpacesVocab.MAINTAINED_RESOURCE_DECLARATION, GRAPH);
507
        if (out.contains(typeSt)) return;
12!
508

509
        out.add(typeSt);
12✔
510
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27✔
511
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27✔
512
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
513
        addProvenance(subject, ctx, out);
12✔
514
    }
3✔
515

516
    // ---------------- ID-prefix enumeration ----------------
517

518
    /**
519
     * Returns the immediate URL-path parent of a Space IRI, after normalisation,
520
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing
521
     * slash, then drops the last path segment after the {@code ://} scheme
522
     * separator. Returns at most one IRI; empty for inputs without a scheme
523
     * separator or without any path beyond the host.
524
     *
525
     * <p>Direct-parent-only semantics matches Nanodash's existing
526
     * {@code SpaceRepository.findSubspaces(...)} URL-regex behaviour. Multi-level
527
     * containment queries should use SPARQL property paths
528
     * ({@code <ancestor> npa:hasSubSpace+ ?descendant}) which walk the chain
529
     * transitively, so deeper descendants remain reachable as long as the
530
     * intermediate Spaces exist.
531
     *
532
     * <p>Examples:
533
     * <pre>
534
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c]
535
     *   https://example.org/space        →  [https://example.org]   (single segment → host)
536
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
537
     *   https://example.org/a/space?q=1  →  [https://example.org/a] (query stripped)
538
     *   https://example.org              →  []                       (no path to strip)
539
     * </pre>
540
     */
541
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
542
        String s = spaceIri.stringValue();
9✔
543
        int hash = s.indexOf('#');
12✔
544
        if (hash >= 0) s = s.substring(0, hash);
21✔
545
        int qmark = s.indexOf('?');
12✔
546
        if (qmark >= 0) s = s.substring(0, qmark);
21✔
547
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39✔
548

549
        int schemeEnd = s.indexOf("://");
12✔
550
        if (schemeEnd < 0) return Collections.emptyList();
12✔
551
        int hostStart = schemeEnd + 3;
12✔
552
        int hostEnd = s.indexOf('/', hostStart);
15✔
553
        if (hostEnd < 0) return Collections.emptyList();   // host-only, nothing to strip
12✔
554

555
        // Drop the last path segment. If that strips us back to the host (single-
556
        // segment path), return the host-only IRI as the immediate parent.
557
        int lastSlash = s.lastIndexOf('/');
12✔
558
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39✔
559
        return List.of(vf.createIRI(parent));
15✔
560
    }
561

562
    // ---------------- shared helpers ----------------
563

564
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
565
        if (ctx.signedBy() != null) {
9!
566
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
567
        }
568
        if (ctx.pubkeyHash() != null) {
9!
569
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
570
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
571
        }
572
        if (ctx.createdAt() != null) {
9!
573
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
574
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
575
        }
576
    }
3✔
577

578
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
579
        for (IRI c : candidates) {
30✔
580
            if (types.contains(c)) return true;
18✔
581
        }
3✔
582
        return false;
6✔
583
    }
584

585
    // ---------------- load-number stamping ----------------
586

587
    /**
588
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
589
     * be called by the loader once per nanopub, in the same transaction as the
590
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
591
     * in the admin graph so the materializer's delta cycles know the horizon.
592
     *
593
     * @param npId        nanopub IRI
594
     * @param loadNumber  the load counter value
595
     * @return two statements: load-number stamp + current-load-counter value
596
     */
597
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
598
        List<Statement> out = new ArrayList<>(2);
15✔
599
        Literal lit = vf.createLiteral(loadNumber);
12✔
600
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27✔
601
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27✔
602
        return out;
6✔
603
    }
604

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