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

knowledgepixels / nanopub-query / 26211688342

21 May 2026 07:22AM UTC coverage: 58.112% (+0.09%) from 58.023%
26211688342

push

github

web-flow
Merge pull request #105 from knowledgepixels/fix/narrow-spaces-load-rule

Narrow spaces-repo load gate to space-relevant nanopubs and their invalidators

476 of 906 branches covered (52.54%)

Branch coverage included in aggregate %.

1322 of 2188 relevant lines covered (60.42%)

9.28 hits per line

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

89.58
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;
6✔
57

58
    /**
59
     * The set of nanopub-level type/predicate IRIs that make a nanopub "space-relevant"
60
     * — i.e., that dispatch to one of the per-shape extractors in {@link #extract}.
61
     * Shared with {@link NanopubLoader} so the spaces-load gate and the invalidation
62
     * propagation paths agree on a single definition of "space-relevant" without
63
     * needing to re-run the extractor.
64
     *
65
     * <p>Membership is checked against {@link NanopubUtils#getTypes(Nanopub)}, which
66
     * includes both {@code rdf:type} / {@code npx:hasNanopubType} declarations and,
67
     * for single-predicate-assertion nanopubs, the predicate itself — so predicate
68
     * markers like {@link GEN#HAS_ROLE} and {@link GEN#IS_MAINTAINED_BY} can appear
69
     * as types here.
70
     */
71
    public static final Set<IRI> TRIGGER_TYPES;
72
    static {
73
        Set<IRI> s = new LinkedHashSet<>();
12✔
74
        s.add(GEN.SPACE);
12✔
75
        s.add(GEN.HAS_ROLE);
12✔
76
        s.add(GEN.SPACE_MEMBER_ROLE);
12✔
77
        s.add(GEN.ROLE_INSTANTIATION);
12✔
78
        s.add(GEN.IS_SUB_SPACE_OF);
12✔
79
        s.add(GEN.MAINTAINED_RESOURCE);
12✔
80
        s.add(GEN.IS_MAINTAINED_BY);
12✔
81
        s.addAll(BackcompatRolePredicates.ALL);
12✔
82
        TRIGGER_TYPES = Collections.unmodifiableSet(s);
9✔
83
    }
3✔
84

85
    private SpacesExtractor() {
86
    }
87

88
    /**
89
     * Returns {@code true} iff at least one of {@code types} is in
90
     * {@link #TRIGGER_TYPES} — i.e., the nanopub carries a type that dispatches
91
     * to one of the extractor branches. Callers should typically pass
92
     * {@code NanopubUtils.getTypes(np)} so that single-predicate-assertion
93
     * auto-typing is included.
94
     */
95
    public static boolean isSpaceRelevant(Set<IRI> types) {
96
        for (IRI t : types) {
30✔
97
            if (TRIGGER_TYPES.contains(t)) return true;
18✔
98
        }
3✔
99
        return false;
6✔
100
    }
101

102
    /**
103
     * Bundles the information a single extraction needs beyond the nanopub itself.
104
     *
105
     * @param artifactCode trusty-URI artifact code of {@code np} (used for minting
106
     *                     {@code npari:}/{@code npara:}/{@code npard:}/{@code npadef:}
107
     *                     subject IRIs).
108
     * @param signedBy     signer agent IRI from pubinfo, or {@code null} if absent.
109
     * @param pubkeyHash   hash of the signing public key, or {@code null} if absent.
110
     * @param createdAt    creation timestamp, or {@code null} if the nanopub lacks one.
111
     */
112
    public record Context(String artifactCode, IRI signedBy, String pubkeyHash, Date createdAt) {
45✔
113
    }
114

115
    /**
116
     * Runs the extractor on a loaded nanopub. Returns an empty list if the nanopub is
117
     * not space-relevant.
118
     *
119
     * @param np  the nanopub to inspect
120
     * @param ctx the extraction context
121
     * @return statements to write into {@code npa:spacesGraph}
122
     */
123
    public static List<Statement> extract(Nanopub np, Context ctx) {
124
        Set<IRI> types = NanopubUtils.getTypes(np);
9✔
125
        if (!isSpaceRelevant(types)) return Collections.emptyList();
15✔
126

127
        List<Statement> out = new ArrayList<>();
12✔
128

129
        boolean isSpace = types.contains(GEN.SPACE);
12✔
130
        boolean isHasRole = types.contains(GEN.HAS_ROLE);
12✔
131
        boolean isSpaceMemberRole = types.contains(GEN.SPACE_MEMBER_ROLE);
12✔
132
        boolean isRoleInstantiation = types.contains(GEN.ROLE_INSTANTIATION)
18!
133
                || anyMatch(types, BackcompatRolePredicates.ALL);
18✔
134
        boolean isSubSpaceOf = types.contains(GEN.IS_SUB_SPACE_OF);
12✔
135
        // Maintained-resource nanopubs use either the resource-class marker
136
        // (gen:MaintainedResource — what Nanodash currently writes) or the
137
        // predicate marker (gen:isMaintainedBy — single-predicate-assertion
138
        // auto-typing or explicit npx:hasNanopubType). Both shapes carry the
139
        // same <r> gen:isMaintainedBy <s> triple in the assertion.
140
        boolean isMaintainedResource = types.contains(GEN.MAINTAINED_RESOURCE)
18✔
141
                || types.contains(GEN.IS_MAINTAINED_BY);
18✔
142

143
        if (isSpace) extractSpace(np, ctx, out);
18✔
144
        if (isHasRole) extractHasRole(np, ctx, out);
18✔
145
        if (isSpaceMemberRole) extractSpaceMemberRole(np, ctx, out);
18✔
146
        if (isRoleInstantiation) extractRoleInstantiation(np, ctx, out);
18✔
147
        if (isSubSpaceOf) extractSubSpaceOf(np, ctx, out);
18✔
148
        if (isMaintainedResource) extractIsMaintainedBy(np, ctx, out);
18✔
149

150
        return out;
6✔
151
    }
152

153
    // ---------------- gen:Space ----------------
154

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

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

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

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

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

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

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

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

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

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

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

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

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

282
    // ---------------- gen:hasRole (role attachment) ----------------
283

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

303
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
304

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

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

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

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

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

369
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
370

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

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

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

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

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

429
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
430

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

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

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

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

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

490
    // ---------------- gen:isMaintainedBy ----------------
491

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

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

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

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

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

554
    // ---------------- ID-prefix enumeration ----------------
555

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

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

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

600
    // ---------------- shared helpers ----------------
601

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

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

623
    // ---------------- load-number stamping ----------------
624

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

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