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

knowledgepixels / nanopub-query / 25609473549

09 May 2026 07:10PM UTC coverage: 58.478% (+0.8%) from 57.642%
25609473549

Pull #98

github

web-flow
Merge 8fde48ee0 into 68e6e3645
Pull Request #98: Maintained-resource support (#97)

493 of 918 branches covered (53.7%)

Branch coverage included in aggregate %.

1297 of 2143 relevant lines covered (60.52%)

9.42 hits per line

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

90.12
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
    /**
116
     * Emits the {@link SpacesVocab#INVALIDATION} entry for an invalidator nanopub
117
     * whose target has at least one space-relevant type. Caller (the loader's
118
     * invalidation-propagation loop) passes in the types of the invalidated
119
     * nanopub so we can check space-relevance without re-reading the meta repo.
120
     *
121
     * @param thisNp        the invalidator nanopub
122
     * @param invalidatedNp URI of the nanopub being invalidated
123
     * @param targetTypes   types of the invalidated nanopub (from the meta repo)
124
     * @param ctx           extraction context for the invalidator
125
     * @return the invalidation entry statements, or empty if no target type is space-relevant
126
     */
127
    public static List<Statement> extractInvalidation(Nanopub thisNp, IRI invalidatedNp,
128
                                                      Set<IRI> targetTypes, Context ctx) {
129
        if (!isSpaceRelevant(targetTypes)) return Collections.emptyList();
15✔
130
        IRI subject = SpacesVocab.forInvalidation(ctx.artifactCode());
12✔
131
        List<Statement> out = new ArrayList<>();
12✔
132
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.INVALIDATION, GRAPH));
27✔
133
        out.add(vf.createStatement(subject, SpacesVocab.INVALIDATES, invalidatedNp, GRAPH));
27✔
134
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, thisNp.getUri(), GRAPH));
30✔
135
        addProvenance(subject, ctx, out);
12✔
136
        return out;
6✔
137
    }
138

139
    /** True iff any type in {@code types} is a predefined type or a backwards-compat predicate. */
140
    public static boolean isSpaceRelevant(Set<IRI> types) {
141
        return types.contains(GEN.SPACE)
21✔
142
                || types.contains(GEN.HAS_ROLE)
12✔
143
                || types.contains(GEN.SPACE_MEMBER_ROLE)
12✔
144
                || types.contains(GEN.ROLE_INSTANTIATION)
12✔
145
                || types.contains(GEN.IS_SUB_SPACE_OF)
12✔
146
                || types.contains(GEN.IS_MAINTAINED_BY)
12✔
147
                || types.contains(GEN.MAINTAINED_RESOURCE)
12✔
148
                || anyMatch(types, BackcompatRolePredicates.ALL);
15✔
149
    }
150

151
    // ---------------- gen:Space ----------------
152

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

161
        // Rooted case: gen:hasRootDefinition explicitly declared.
162
        for (Statement st : np.getAssertion()) {
33✔
163
            if (!st.getPredicate().equals(GEN.HAS_ROOT_DEFINITION)) continue;
18✔
164
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
165
            if (!(st.getObject() instanceof IRI rootUri)) continue;
27!
166
            String rootNanopubId = TrustyUriUtils.getArtifactCode(rootUri.stringValue());
12✔
167
            if (rootNanopubId == null || rootNanopubId.isEmpty()) {
15!
168
                log.warn("Ignoring space {}: gen:hasRootDefinition target is not a trusty URI: {}",
×
169
                        spaceIri, rootUri);
170
                continue;
×
171
            }
172
            if (!handled.add(spaceIri)) continue;
12!
173
            emitSpaceEntry(np, ctx, spaceIri, rootUri, rootNanopubId, adminAgents, out);
24✔
174
        }
3✔
175

176
        // Rootless transition case: any Space IRI in the assertion that didn't get a
177
        // hasRootDefinition triple is treated as if it were its own root. Detect by
178
        // looking for triples that reference a Space IRI we haven't handled yet —
179
        // typically via gen:hasAdmin subjects or the rdf:type gen:Space triple on a
180
        // blank-node assertion subject. The common template publishes the Space IRI
181
        // as the subject of at least one triple in the assertion, so we scan for that.
182
        for (Statement st : np.getAssertion()) {
33✔
183
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
184
            if (handled.contains(spaceIri)) continue;
15✔
185
            // Skip IRIs that clearly aren't Space IRIs (role IRIs embedded in this nanopub).
186
            if (spaceIri.stringValue().startsWith(np.getUri().stringValue())) continue;
21!
187
            // Require at least one structural signal that this is a Space IRI:
188
            // an rdf:type gen:Space, or a gen:hasAdmin triple with this as subject.
189
            if (!looksLikeSpaceIri(np, spaceIri)) continue;
15✔
190
            handled.add(spaceIri);
12✔
191
            String rootNanopubId = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
192
            if (rootNanopubId == null || rootNanopubId.isEmpty()) continue;
15!
193
            emitSpaceEntry(np, ctx, spaceIri, np.getUri(), rootNanopubId, adminAgents, out);
27✔
194
        }
3✔
195
    }
3✔
196

197
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
198
                                       String rootNanopubId, List<IRI> adminAgents,
199
                                       List<Statement> out) {
200
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15✔
201
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9✔
202
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12✔
203

204
        // Aggregate entry: contributor-independent, reinforced on every contribution.
205
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27✔
206
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27✔
207
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27✔
208

209
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
210
        // fallback in the materializer. Same triples on every contributor (RDF set
211
        // semantics dedups them).
212
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33✔
213
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27✔
214
        }
3✔
215

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

221
        // Embedded gen:isMaintainedBy triples in this gen:Space nanopub: emit one
222
        // MaintainedResourceDeclaration per (resourceIri, spaceIri) pair where the
223
        // object equals the Space being defined. Same shape as the standalone path.
224
        emitMaintainedResourceDeclarations(np, ctx, spaceIri, out);
15✔
225

226
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
227
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
228
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
229
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
230
        addProvenance(defIri, ctx, out);
12✔
231

232
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
233
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
234
        if (isOwnRoot) {
6✔
235
            for (IRI adminAgent : adminAgents) {
30✔
236
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
237
            }
3✔
238
        }
239

240
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
241
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
242
        if (!adminAgents.isEmpty()) {
9!
243
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
244
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
245
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
246
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27✔
247
            for (IRI adminAgent : adminAgents) {
30✔
248
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27✔
249
            }
3✔
250
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
251
            addProvenance(riIri, ctx, out);
12✔
252
        }
253
    }
3✔
254

255
    /**
256
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
257
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
258
     * assertion contains {@code candidate rdf:type gen:Space} or
259
     * {@code candidate gen:hasAdmin ?x}.
260
     */
261
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
262
        for (Statement st : np.getAssertion()) {
33✔
263
            if (!candidate.equals(st.getSubject())) continue;
18✔
264
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) return true;
36!
265
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) return true;
15!
266
        }
3✔
267
        return false;
6✔
268
    }
269

270
    private static List<IRI> collectAdminAgents(Nanopub np) {
271
        Set<IRI> agents = new LinkedHashSet<>();
12✔
272
        for (Statement st : np.getAssertion()) {
33✔
273
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) continue;
18✔
274
            if (!(st.getObject() instanceof IRI agent)) continue;
27!
275
            agents.add(agent);
12✔
276
        }
3✔
277
        return new ArrayList<>(agents);
15✔
278
    }
279

280
    // ---------------- gen:hasRole (role attachment) ----------------
281

282
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
283
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
284
        for (Statement st : np.getAssertion()) {
33!
285
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) continue;
15!
286
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
287
            if (!(st.getObject() instanceof IRI roleIri)) continue;
27!
288
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12✔
289
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27✔
290
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
291
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27✔
292
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
293
            addProvenance(subject, ctx, out);
12✔
294
            // One attachment per nanopub — the subject IRI is derived from the nanopub
295
            // artifact code so multiple hasRole triples in the same nanopub would collide.
296
            // If that case shows up in practice, we'll refine the subject-minting scheme.
297
            return;
3✔
298
        }
299
    }
×
300

301
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
302

303
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
304
        // The role IRI is embedded in this nanopub, so look for an assertion statement
305
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
306
        // with the nanopub IRI (valid embedded mint).
307
        IRI roleIri = null;
6✔
308
        for (Statement st : np.getAssertion()) {
33✔
309
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
310
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) continue;
18✔
311
            if (!(st.getSubject() instanceof IRI candidate)) continue;
27!
312
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) continue;
24✔
313
            roleIri = candidate;
6✔
314
            break;
3✔
315
        }
316
        if (roleIri == null) return;
9✔
317

318
        IRI roleType = findRoleTier(np, roleIri);
12✔
319
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
320
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
321

322
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12✔
323
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27✔
324
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27✔
325
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27✔
326
        for (IRI reg : regulars) {
30✔
327
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27✔
328
        }
3✔
329
        for (IRI inv : inverses) {
30✔
330
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27✔
331
        }
3✔
332
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
333
        if (ctx.createdAt() != null) {
9!
334
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36✔
335
        }
336
    }
3✔
337

338
    /**
339
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
340
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
341
     * {@code gen:ObserverRole} if none is declared.
342
     */
343
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
344
        for (Statement st : np.getAssertion()) {
33✔
345
            if (!roleIri.equals(st.getSubject())) continue;
15!
346
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
347
            if (!(st.getObject() instanceof IRI type)) continue;
27!
348
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30!
349
                    || GEN.OBSERVER_ROLE.equals(type)) {
6!
350
                return type;
6✔
351
            }
352
        }
3✔
353
        return GEN.OBSERVER_ROLE;
6✔
354
    }
355

356
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
357
        List<IRI> out = new ArrayList<>();
12✔
358
        for (Statement st : np.getAssertion()) {
33✔
359
            if (!roleIri.equals(st.getSubject())) continue;
15!
360
            if (!predicate.equals(st.getPredicate())) continue;
18✔
361
            if (!(st.getObject() instanceof IRI obj)) continue;
27!
362
            out.add(obj);
12✔
363
        }
3✔
364
        return out;
6✔
365
    }
366

367
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
368

369
    private static void extractRoleInstantiation(Nanopub np, Context ctx, List<Statement> out) {
370
        // Find the assignment triple. Directionality (matches the publisher convention
371
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
372
        // nanopubs):
373
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
374
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
375
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
376
        // The 14 backwards-compat predicates are classified in
377
        // {@link BackcompatRolePredicates#DIRECTIONS}. User-defined role predicates from
378
        // gen:SpaceMemberRole nanopubs aren't resolvable here without the role-declaration
379
        // registry; FIXME: the materializer in PR 2 should refine direction for the
380
        // typed-but-unknown-predicate case. For now we emit only triples whose predicate
381
        // we know the direction of.
382
        for (Statement st : np.getAssertion()) {
33!
383
            IRI predicate = st.getPredicate();
9✔
384
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9✔
385
            if (direction == null) continue;
6!
386
            if (!(st.getSubject() instanceof IRI subjIri)) continue;
27!
387
            if (!(st.getObject() instanceof IRI objIri)) continue;
27!
388

389
            IRI spaceSide;
390
            IRI agentSide;
391
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
392
                agentSide = subjIri;
6✔
393
                spaceSide = objIri;
9✔
394
            } else {
395
                spaceSide = subjIri;
6✔
396
                agentSide = objIri;
6✔
397
            }
398

399
            // Deduplicate against the (possibly already emitted) admin instantiation
400
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
401
            // have a gen:hasAdmin triple that the backcompat list also catches. The
402
            // subject IRI is the same (derived from artifact code) and the payload
403
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
404
            // entry on this subject.
405
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
406
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21✔
407
            if (out.contains(typeSt)) return;
12!
408

409
            out.add(typeSt);
12✔
410
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
411
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
412
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
413
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
414
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
415
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
416
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
417
            addProvenance(subject, ctx, out);
12✔
418
            return;
3✔
419
        }
420
    }
×
421

422
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
423
        if (GEN.HAS_ADMIN.equals(predicate)) return BackcompatRolePredicates.Direction.INVERSE;
12!
424
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
425
    }
426

427
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
428

429
    /**
430
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
431
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
432
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
433
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
434
     */
435
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
436
        for (Statement st : np.getAssertion()) {
33✔
437
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
15!
438
            if (!(st.getSubject() instanceof IRI childIri)) continue;
27!
439
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
440
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18✔
441
        }
3✔
442
    }
3✔
443

444
    /**
445
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
446
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
447
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
448
     * this particular Space). Self-loops are rejected.
449
     */
450
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
451
                                                 List<Statement> out) {
452
        for (Statement st : np.getAssertion()) {
33✔
453
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
18✔
454
            if (!spaceIri.equals(st.getSubject())) continue;
18✔
455
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
456
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18✔
457
        }
3✔
458
    }
3✔
459

460
    /**
461
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
462
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
463
     * parents without subject collision. Self-loops are silently dropped.
464
     */
465
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
466
                                                IRI parentIri, List<Statement> out) {
467
        if (childIri.equals(parentIri)) {
12✔
468
            log.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18✔
469
            return;
3✔
470
        }
471
        String parentHash = Utils.createHash(parentIri);
9✔
472
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15✔
473

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

481
        out.add(typeSt);
12✔
482
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
483
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
484
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
485
        addProvenance(subject, ctx, out);
12✔
486
    }
3✔
487

488
    // ---------------- gen:isMaintainedBy ----------------
489

490
    /**
491
     * Standalone {@code gen:isMaintainedBy} nanopub: every
492
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triple in the assertion emits
493
     * one {@code npa:MaintainedResourceDeclaration}. Multi-triple assertions are
494
     * allowed; one entry per pair. Self-loops ({@code <X> gen:isMaintainedBy <X>}) are
495
     * rejected.
496
     */
497
    private static void extractIsMaintainedBy(Nanopub np, Context ctx, List<Statement> out) {
498
        for (Statement st : np.getAssertion()) {
33✔
499
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) continue;
18✔
500
            if (!(st.getSubject() instanceof IRI resourceIri)) continue;
27!
501
            if (!(st.getObject() instanceof IRI spaceIri)) continue;
27!
502
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
503
        }
3✔
504
    }
3✔
505

506
    /**
507
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
508
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triples (object must equal
509
     * the Space IRI we're emitting an entry for, so the maintained-resource
510
     * declaration is bound to this particular Space). Self-loops are rejected.
511
     */
512
    private static void emitMaintainedResourceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
513
                                                           List<Statement> out) {
514
        for (Statement st : np.getAssertion()) {
33✔
515
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) continue;
18✔
516
            if (!spaceIri.equals(st.getObject())) continue;
18✔
517
            if (!(st.getSubject() instanceof IRI resourceIri)) continue;
27!
518
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
519
        }
3✔
520
    }
3✔
521

522
    /**
523
     * Emits one {@code npa:MaintainedResourceDeclaration} entry, keyed by
524
     * {@code (artifactCode, resourceHash)} so a single nanopub can declare multiple
525
     * maintained resources without subject collision. Self-loops are silently dropped.
526
     */
527
    private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, IRI resourceIri,
528
                                                          IRI spaceIri, List<Statement> out) {
529
        if (resourceIri.equals(spaceIri)) {
12✔
530
            log.debug("Ignoring self-loop maintained-resource declaration on {} in {}",
15✔
531
                    resourceIri, np.getUri());
3✔
532
            return;
3✔
533
        }
534
        String resourceHash = Utils.createHash(resourceIri);
9✔
535
        IRI subject = SpacesVocab.forMaintainedResourceDeclaration(ctx.artifactCode(), resourceHash);
15✔
536

537
        // Idempotence: the embedded (gen:Space) and standalone (gen:isMaintainedBy)
538
        // paths can both fire on the same (np, resource, space) combination if a
539
        // gen:Space nanopub somehow ends up typed gen:isMaintainedBy as well. Skip if
540
        // we've already emitted the type triple for this subject.
541
        Statement typeSt = vf.createStatement(subject, RDF.TYPE,
21✔
542
                SpacesVocab.MAINTAINED_RESOURCE_DECLARATION, GRAPH);
543
        if (out.contains(typeSt)) return;
12!
544

545
        out.add(typeSt);
12✔
546
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27✔
547
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27✔
548
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
549
        addProvenance(subject, ctx, out);
12✔
550
    }
3✔
551

552
    // ---------------- ID-prefix enumeration ----------------
553

554
    /**
555
     * Returns the immediate URL-path parent of a Space IRI, after normalisation,
556
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing
557
     * slash, then drops the last path segment after the {@code ://} scheme
558
     * separator. Returns at most one IRI; empty for inputs without a scheme
559
     * separator or without any path beyond the host.
560
     *
561
     * <p>Direct-parent-only semantics matches Nanodash's existing
562
     * {@code SpaceRepository.findSubspaces(...)} URL-regex behaviour. Multi-level
563
     * containment queries should use SPARQL property paths
564
     * ({@code <ancestor> npa:hasSubSpace+ ?descendant}) which walk the chain
565
     * transitively, so deeper descendants remain reachable as long as the
566
     * intermediate Spaces exist.
567
     *
568
     * <p>Examples:
569
     * <pre>
570
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c]
571
     *   https://example.org/space        →  [https://example.org]   (single segment → host)
572
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
573
     *   https://example.org/a/space?q=1  →  [https://example.org/a] (query stripped)
574
     *   https://example.org              →  []                       (no path to strip)
575
     * </pre>
576
     */
577
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
578
        String s = spaceIri.stringValue();
9✔
579
        int hash = s.indexOf('#');
12✔
580
        if (hash >= 0) s = s.substring(0, hash);
21✔
581
        int qmark = s.indexOf('?');
12✔
582
        if (qmark >= 0) s = s.substring(0, qmark);
21✔
583
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39✔
584

585
        int schemeEnd = s.indexOf("://");
12✔
586
        if (schemeEnd < 0) return Collections.emptyList();
12✔
587
        int hostStart = schemeEnd + 3;
12✔
588
        int hostEnd = s.indexOf('/', hostStart);
15✔
589
        if (hostEnd < 0) return Collections.emptyList();   // host-only, nothing to strip
12✔
590

591
        // Drop the last path segment. If that strips us back to the host (single-
592
        // segment path), return the host-only IRI as the immediate parent.
593
        int lastSlash = s.lastIndexOf('/');
12✔
594
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39✔
595
        return List.of(vf.createIRI(parent));
15✔
596
    }
597

598
    // ---------------- shared helpers ----------------
599

600
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
601
        if (ctx.signedBy() != null) {
9!
602
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
603
        }
604
        if (ctx.pubkeyHash() != null) {
9!
605
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
606
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
607
        }
608
        if (ctx.createdAt() != null) {
9!
609
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
610
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
611
        }
612
    }
3✔
613

614
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
615
        for (IRI c : candidates) {
30✔
616
            if (types.contains(c)) return true;
18✔
617
        }
3✔
618
        return false;
6✔
619
    }
620

621
    // ---------------- load-number stamping ----------------
622

623
    /**
624
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
625
     * be called by the loader once per nanopub, in the same transaction as the
626
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
627
     * in the admin graph so the materializer's delta cycles know the horizon.
628
     *
629
     * @param npId        nanopub IRI
630
     * @param loadNumber  the load counter value
631
     * @return two statements: load-number stamp + current-load-counter value
632
     */
633
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
634
        List<Statement> out = new ArrayList<>(2);
15✔
635
        Literal lit = vf.createLiteral(loadNumber);
12✔
636
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27✔
637
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27✔
638
        return out;
6✔
639
    }
640

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