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

knowledgepixels / nanopub-query / 25598398314

09 May 2026 10:04AM UTC coverage: 58.369% (+0.7%) from 57.642%
25598398314

Pull #98

github

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

487 of 912 branches covered (53.4%)

Branch coverage included in aggregate %.

1295 of 2141 relevant lines covered (60.49%)

9.41 hits per line

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

89.96
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
        boolean isMaintainedBy = types.contains(GEN.IS_MAINTAINED_BY);
12✔
93

94
        if (!isSpace && !isHasRole && !isSpaceMemberRole && !isRoleInstantiation
36✔
95
                && !isSubSpaceOf && !isMaintainedBy) {
96
            return Collections.emptyList();
6✔
97
        }
98

99
        if (isSpace) extractSpace(np, ctx, out);
18✔
100
        if (isHasRole) extractHasRole(np, ctx, out);
18✔
101
        if (isSpaceMemberRole) extractSpaceMemberRole(np, ctx, out);
18✔
102
        if (isRoleInstantiation) extractRoleInstantiation(np, ctx, out);
18✔
103
        if (isSubSpaceOf) extractSubSpaceOf(np, ctx, out);
18✔
104
        if (isMaintainedBy) extractIsMaintainedBy(np, ctx, out);
18✔
105

106
        return out;
6✔
107
    }
108

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

133
    /** True iff any type in {@code types} is a predefined type or a backwards-compat predicate. */
134
    public static boolean isSpaceRelevant(Set<IRI> types) {
135
        return types.contains(GEN.SPACE)
21✔
136
                || types.contains(GEN.HAS_ROLE)
12✔
137
                || types.contains(GEN.SPACE_MEMBER_ROLE)
12✔
138
                || types.contains(GEN.ROLE_INSTANTIATION)
12✔
139
                || types.contains(GEN.IS_SUB_SPACE_OF)
12✔
140
                || types.contains(GEN.IS_MAINTAINED_BY)
12✔
141
                || anyMatch(types, BackcompatRolePredicates.ALL);
15✔
142
    }
143

144
    // ---------------- gen:Space ----------------
145

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

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

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

190
    private static void emitSpaceEntry(Nanopub np, Context ctx, IRI spaceIri, IRI rootUri,
191
                                       String rootNanopubId, List<IRI> adminAgents,
192
                                       List<Statement> out) {
193
        String spaceRef = rootNanopubId + "_" + Utils.createHash(spaceIri);
15✔
194
        IRI refIri = SpacesVocab.forSpaceRef(spaceRef);
9✔
195
        IRI defIri = SpacesVocab.forSpaceDefinition(ctx.artifactCode());
12✔
196

197
        // Aggregate entry: contributor-independent, reinforced on every contribution.
198
        out.add(vf.createStatement(refIri, RDF.TYPE, SpacesVocab.SPACE_REF, GRAPH));
27✔
199
        out.add(vf.createStatement(refIri, SpacesVocab.SPACE_IRI, spaceIri, GRAPH));
27✔
200
        out.add(vf.createStatement(refIri, SpacesVocab.ROOT_NANOPUB, rootUri, GRAPH));
27✔
201

202
        // Identity-derived path-prefix enumeration powering the URL-prefix sub-space
203
        // fallback in the materializer. Same triples on every contributor (RDF set
204
        // semantics dedups them).
205
        for (IRI prefix : enumerateIdPrefixes(spaceIri)) {
33✔
206
            out.add(vf.createStatement(refIri, SpacesVocab.HAS_ID_PREFIX, prefix, GRAPH));
27✔
207
        }
3✔
208

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

214
        // Embedded gen:isMaintainedBy triples in this gen:Space nanopub: emit one
215
        // MaintainedResourceDeclaration per (resourceIri, spaceIri) pair where the
216
        // object equals the Space being defined. Same shape as the standalone path.
217
        emitMaintainedResourceDeclarations(np, ctx, spaceIri, out);
15✔
218

219
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
220
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
221
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
222
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
223
        addProvenance(defIri, ctx, out);
12✔
224

225
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
226
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
227
        if (isOwnRoot) {
6✔
228
            for (IRI adminAgent : adminAgents) {
30✔
229
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
230
            }
3✔
231
        }
232

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

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

263
    private static List<IRI> collectAdminAgents(Nanopub np) {
264
        Set<IRI> agents = new LinkedHashSet<>();
12✔
265
        for (Statement st : np.getAssertion()) {
33✔
266
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) continue;
18✔
267
            if (!(st.getObject() instanceof IRI agent)) continue;
27!
268
            agents.add(agent);
12✔
269
        }
3✔
270
        return new ArrayList<>(agents);
15✔
271
    }
272

273
    // ---------------- gen:hasRole (role attachment) ----------------
274

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

294
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
295

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

311
        IRI roleType = findRoleTier(np, roleIri);
12✔
312
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
313
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
314

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

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

349
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
350
        List<IRI> out = new ArrayList<>();
12✔
351
        for (Statement st : np.getAssertion()) {
33✔
352
            if (!roleIri.equals(st.getSubject())) continue;
15!
353
            if (!predicate.equals(st.getPredicate())) continue;
18✔
354
            if (!(st.getObject() instanceof IRI obj)) continue;
27!
355
            out.add(obj);
12✔
356
        }
3✔
357
        return out;
6✔
358
    }
359

360
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
361

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

382
            IRI spaceSide;
383
            IRI agentSide;
384
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
385
                agentSide = subjIri;
6✔
386
                spaceSide = objIri;
9✔
387
            } else {
388
                spaceSide = subjIri;
6✔
389
                agentSide = objIri;
6✔
390
            }
391

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

402
            out.add(typeSt);
12✔
403
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
404
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
405
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
406
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
407
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
408
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
409
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
410
            addProvenance(subject, ctx, out);
12✔
411
            return;
3✔
412
        }
413
    }
×
414

415
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
416
        if (GEN.HAS_ADMIN.equals(predicate)) return BackcompatRolePredicates.Direction.INVERSE;
12!
417
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
418
    }
419

420
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
421

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

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

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

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

474
        out.add(typeSt);
12✔
475
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
476
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
477
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
478
        addProvenance(subject, ctx, out);
12✔
479
    }
3✔
480

481
    // ---------------- gen:isMaintainedBy ----------------
482

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

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

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

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

538
        out.add(typeSt);
12✔
539
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27✔
540
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27✔
541
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
542
        addProvenance(subject, ctx, out);
12✔
543
    }
3✔
544

545
    // ---------------- ID-prefix enumeration ----------------
546

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

578
        int schemeEnd = s.indexOf("://");
12✔
579
        if (schemeEnd < 0) return Collections.emptyList();
12✔
580
        int hostStart = schemeEnd + 3;
12✔
581
        int hostEnd = s.indexOf('/', hostStart);
15✔
582
        if (hostEnd < 0) return Collections.emptyList();   // host-only, nothing to strip
12✔
583

584
        // Drop the last path segment. If that strips us back to the host (single-
585
        // segment path), return the host-only IRI as the immediate parent.
586
        int lastSlash = s.lastIndexOf('/');
12✔
587
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39✔
588
        return List.of(vf.createIRI(parent));
15✔
589
    }
590

591
    // ---------------- shared helpers ----------------
592

593
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
594
        if (ctx.signedBy() != null) {
9!
595
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
596
        }
597
        if (ctx.pubkeyHash() != null) {
9!
598
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
599
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
600
        }
601
        if (ctx.createdAt() != null) {
9!
602
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
603
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
604
        }
605
    }
3✔
606

607
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
608
        for (IRI c : candidates) {
30✔
609
            if (types.contains(c)) return true;
18✔
610
        }
3✔
611
        return false;
6✔
612
    }
613

614
    // ---------------- load-number stamping ----------------
615

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

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