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

knowledgepixels / nanopub-query / 26504038865

27 May 2026 09:52AM UTC coverage: 59.597% (+0.3%) from 59.27%
26504038865

push

github

web-flow
Merge pull request #115 from knowledgepixels/fix/issue-113-sameas-alias

fix(spaces): honor owl:sameAs space aliases in /repo/spaces materializer (#113)

480 of 896 branches covered (53.57%)

Branch coverage included in aggregate %.

1383 of 2230 relevant lines covered (62.02%)

9.5 hits per line

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

90.19
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.LinkedHashMap;
8
import java.util.LinkedHashSet;
9
import java.util.List;
10
import java.util.Map;
11
import java.util.Set;
12

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

29
import com.knowledgepixels.query.vocabulary.BackcompatRolePredicates;
30
import com.knowledgepixels.query.vocabulary.GEN;
31
import com.knowledgepixels.query.vocabulary.SpacesVocab;
32

33
import net.trustyuri.TrustyUriUtils;
34

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

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

57
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
58

59
    private static final IRI GRAPH = SpacesVocab.SPACES_GRAPH;
6✔
60

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

88
    private SpacesExtractor() {
89
    }
90

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

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

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

130
        List<Statement> out = new ArrayList<>();
12✔
131

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

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

153
        return out;
6✔
154
    }
155

156
    // ---------------- gen:Space ----------------
157

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

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

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

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

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

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

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

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

231
        // Embedded owl:sameAs triples: <spaceIri> owl:sameAs <aliasIri> declares that
232
        // <aliasIri> is an alias of the Space being defined. Emit one
233
        // SpaceAliasDeclaration per (spaceIri, aliasIri) pair so the materializer can
234
        // let this space's admin authority cover roles/members attached to the alias
235
        // (issue #113). Carries provenance — the materializer gates the edge on the
236
        // declaration's publisher being an admin of the canonical space.
237
        emitSpaceAliasDeclarations(np, ctx, spaceIri, out);
15✔
238

239
        // Per-contributor entry: signer, pubkey, created-at, link back to nanopub.
240
        out.add(vf.createStatement(defIri, RDF.TYPE, SpacesVocab.SPACE_DEFINITION, GRAPH));
27✔
241
        out.add(vf.createStatement(defIri, SpacesVocab.FOR_SPACE_REF, refIri, GRAPH));
27✔
242
        out.add(vf.createStatement(defIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
243
        addProvenance(defIri, ctx, out);
12✔
244

245
        // Trust seed: this is the root nanopub iff rootUri equals the nanopub's own URI.
246
        boolean isOwnRoot = rootUri.equals(np.getUri());
15✔
247
        if (isOwnRoot) {
6✔
248
            for (IRI adminAgent : adminAgents) {
30✔
249
                out.add(vf.createStatement(defIri, SpacesVocab.HAS_ROOT_ADMIN, adminAgent, GRAPH));
27✔
250
            }
3✔
251
        }
252

253
        // gen:RoleInstantiation entry for the admins asserted in this gen:Space nanopub,
254
        // so admins show up in the same SPARQL pattern as ordinary admin instantiations.
255
        if (!adminAgents.isEmpty()) {
9!
256
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
257
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
258
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
259
            out.add(vf.createStatement(riIri, SpacesVocab.INVERSE_PROPERTY, GEN.HAS_ADMIN, GRAPH));
27✔
260
            for (IRI adminAgent : adminAgents) {
30✔
261
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, adminAgent, GRAPH));
27✔
262
            }
3✔
263
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
264
            addProvenance(riIri, ctx, out);
12✔
265
        }
266

267
        // Inline non-hasAdmin role triples: a gen:Space nanopub may also assert
268
        // <space> <pred> <agent> (INVERSE) or <agent> <pred> <space> (REGULAR)
269
        // for any of the back-compat role predicates (has-event-facilitator,
270
        // participatedAsParticipantIn, …). Without an extraction path here those
271
        // are silently dropped because gen:Space nanopubs are not auto-typed
272
        // with back-compat predicates (only single-triple-assertion nanopubs are).
273
        // Emit one RoleInstantiation per distinct predicate found, grouping
274
        // multi-agent like the admin case. The subject is disambiguated by a
275
        // hash of the predicate IRI so multiple predicates in one nanopub don't
276
        // collide on the same npari:<artifactCode> subject as the admin RI.
277
        emitInlineRoleInstantiations(np, ctx, spaceIri, out);
15✔
278
    }
3✔
279

280
    /**
281
     * Scans the assertion of a {@code gen:Space} nanopub for inline role triples
282
     * (excluding {@code gen:hasAdmin}, which is handled separately as the trust
283
     * seed), grouping by predicate and emitting one {@link GEN#ROLE_INSTANTIATION}
284
     * per (predicate, direction) pair with multi-valued {@code npa:forAgent}.
285
     */
286
    private static void emitInlineRoleInstantiations(Nanopub np, Context ctx, IRI spaceIri,
287
                                                     List<Statement> out) {
288
        Map<IRI, BackcompatRolePredicates.Direction> directionByPred = new LinkedHashMap<>();
12✔
289
        Map<IRI, Set<IRI>> agentsByPred = new LinkedHashMap<>();
12✔
290
        for (Statement st : np.getAssertion()) {
33✔
291
            IRI predicate = st.getPredicate();
9✔
292
            if (GEN.HAS_ADMIN.equals(predicate)) continue; // already emitted above
15✔
293
            BackcompatRolePredicates.Direction direction = BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
294
            if (direction == null) continue;
9✔
295
            if (!(st.getSubject() instanceof IRI subjIri)) continue;
27!
296
            if (!(st.getObject() instanceof IRI objIri)) continue;
27!
297
            IRI agent;
298
            if (direction == BackcompatRolePredicates.Direction.INVERSE) {
9✔
299
                if (!spaceIri.equals(subjIri)) continue;
12!
300
                agent = objIri;
9✔
301
            } else {
302
                if (!spaceIri.equals(objIri)) continue;
12!
303
                agent = subjIri;
6✔
304
            }
305
            directionByPred.put(predicate, direction);
15✔
306
            agentsByPred.computeIfAbsent(predicate, k -> new LinkedHashSet<>()).add(agent);
36✔
307
        }
3✔
308
        for (Map.Entry<IRI, Set<IRI>> entry : agentsByPred.entrySet()) {
33✔
309
            IRI predicate = entry.getKey();
12✔
310
            BackcompatRolePredicates.Direction direction = directionByPred.get(predicate);
15✔
311
            String predHash = Utils.createHash(predicate.stringValue());
12✔
312
            IRI riIri = SpacesVocab.forRoleInstantiation(ctx.artifactCode(), predHash);
15✔
313
            out.add(vf.createStatement(riIri, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH));
27✔
314
            out.add(vf.createStatement(riIri, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
315
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
316
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
317
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
318
            out.add(vf.createStatement(riIri, directionPredicate, predicate, GRAPH));
27✔
319
            for (IRI agent : entry.getValue()) {
36✔
320
                out.add(vf.createStatement(riIri, SpacesVocab.FOR_AGENT, agent, GRAPH));
27✔
321
            }
3✔
322
            out.add(vf.createStatement(riIri, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
323
            addProvenance(riIri, ctx, out);
12✔
324
        }
3✔
325
    }
3✔
326

327
    /**
328
     * Heuristic: does {@code candidate} look like a Space IRI in {@code np}'s assertion,
329
     * independent of any {@code gen:hasRootDefinition} triple? We accept it if the
330
     * assertion contains {@code candidate rdf:type gen:Space} or
331
     * {@code candidate gen:hasAdmin ?x}.
332
     */
333
    private static boolean looksLikeSpaceIri(Nanopub np, IRI candidate) {
334
        for (Statement st : np.getAssertion()) {
33✔
335
            if (!candidate.equals(st.getSubject())) continue;
18✔
336
            if (st.getPredicate().equals(RDF.TYPE) && GEN.SPACE.equals(st.getObject())) return true;
36!
337
            if (st.getPredicate().equals(GEN.HAS_ADMIN)) return true;
15!
338
        }
3✔
339
        return false;
6✔
340
    }
341

342
    private static List<IRI> collectAdminAgents(Nanopub np) {
343
        Set<IRI> agents = new LinkedHashSet<>();
12✔
344
        for (Statement st : np.getAssertion()) {
33✔
345
            if (!st.getPredicate().equals(GEN.HAS_ADMIN)) continue;
18✔
346
            if (!(st.getObject() instanceof IRI agent)) continue;
27!
347
            agents.add(agent);
12✔
348
        }
3✔
349
        return new ArrayList<>(agents);
15✔
350
    }
351

352
    // ---------------- gen:hasRole (role attachment) ----------------
353

354
    private static void extractHasRole(Nanopub np, Context ctx, List<Statement> out) {
355
        // A gen:hasRole nanopub asserts <space> gen:hasRole <role>.
356
        for (Statement st : np.getAssertion()) {
33!
357
            if (!st.getPredicate().equals(GEN.HAS_ROLE)) continue;
15!
358
            if (!(st.getSubject() instanceof IRI spaceIri)) continue;
27!
359
            if (!(st.getObject() instanceof IRI roleIri)) continue;
27!
360
            IRI subject = SpacesVocab.forRoleAssignment(ctx.artifactCode());
12✔
361
            out.add(vf.createStatement(subject, RDF.TYPE, GEN.ROLE_ASSIGNMENT, GRAPH));
27✔
362
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceIri, GRAPH));
27✔
363
            out.add(vf.createStatement(subject, GEN.HAS_ROLE, roleIri, GRAPH));
27✔
364
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
365
            addProvenance(subject, ctx, out);
12✔
366
            // One attachment per nanopub — the subject IRI is derived from the nanopub
367
            // artifact code so multiple hasRole triples in the same nanopub would collide.
368
            // If that case shows up in practice, we'll refine the subject-minting scheme.
369
            return;
3✔
370
        }
371
    }
×
372

373
    // ---------------- gen:SpaceMemberRole (role declaration) ----------------
374

375
    private static void extractSpaceMemberRole(Nanopub np, Context ctx, List<Statement> out) {
376
        // The role IRI is embedded in this nanopub, so look for an assertion statement
377
        // of the shape <roleIri> rdf:type gen:SpaceMemberRole where <roleIri> starts
378
        // with the nanopub IRI (valid embedded mint).
379
        IRI roleIri = null;
6✔
380
        for (Statement st : np.getAssertion()) {
33✔
381
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
382
            if (!GEN.SPACE_MEMBER_ROLE.equals(st.getObject())) continue;
18✔
383
            if (!(st.getSubject() instanceof IRI candidate)) continue;
27!
384
            if (!candidate.stringValue().startsWith(np.getUri().stringValue())) continue;
24✔
385
            roleIri = candidate;
6✔
386
            break;
3✔
387
        }
388
        if (roleIri == null) return;
9✔
389

390
        IRI roleType = findRoleTier(np, roleIri);
12✔
391
        List<IRI> regulars = collectRolePredicate(np, roleIri, GEN.HAS_REGULAR_PROPERTY);
15✔
392
        List<IRI> inverses = collectRolePredicate(np, roleIri, GEN.HAS_INVERSE_PROPERTY);
15✔
393

394
        IRI subject = SpacesVocab.forRoleDeclaration(ctx.artifactCode());
12✔
395
        out.add(vf.createStatement(subject, RDF.TYPE, SpacesVocab.ROLE_DECLARATION, GRAPH));
27✔
396
        out.add(vf.createStatement(subject, SpacesVocab.ROLE, roleIri, GRAPH));
27✔
397
        out.add(vf.createStatement(subject, SpacesVocab.HAS_ROLE_TYPE, roleType, GRAPH));
27✔
398
        for (IRI reg : regulars) {
30✔
399
            out.add(vf.createStatement(subject, GEN.HAS_REGULAR_PROPERTY, reg, GRAPH));
27✔
400
        }
3✔
401
        for (IRI inv : inverses) {
30✔
402
            out.add(vf.createStatement(subject, GEN.HAS_INVERSE_PROPERTY, inv, GRAPH));
27✔
403
        }
3✔
404
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
405
        if (ctx.createdAt() != null) {
9!
406
            out.add(vf.createStatement(subject, DCTERMS.CREATED, vf.createLiteral(ctx.createdAt()), GRAPH));
36✔
407
        }
408
    }
3✔
409

410
    /**
411
     * Looks for a tier rdf:type ({@code gen:MaintainerRole} / {@code gen:MemberRole} /
412
     * {@code gen:ObserverRole}) on the role IRI in the assertion; defaults to
413
     * {@code gen:ObserverRole} if none is declared.
414
     */
415
    private static IRI findRoleTier(Nanopub np, IRI roleIri) {
416
        for (Statement st : np.getAssertion()) {
33✔
417
            if (!roleIri.equals(st.getSubject())) continue;
15!
418
            if (!st.getPredicate().equals(RDF.TYPE)) continue;
15!
419
            if (!(st.getObject() instanceof IRI type)) continue;
27!
420
            if (GEN.MAINTAINER_ROLE.equals(type) || GEN.MEMBER_ROLE.equals(type)
30!
421
                    || GEN.OBSERVER_ROLE.equals(type)) {
6!
422
                return type;
6✔
423
            }
424
        }
3✔
425
        return GEN.OBSERVER_ROLE;
6✔
426
    }
427

428
    private static List<IRI> collectRolePredicate(Nanopub np, IRI roleIri, IRI predicate) {
429
        List<IRI> out = new ArrayList<>();
12✔
430
        for (Statement st : np.getAssertion()) {
33✔
431
            if (!roleIri.equals(st.getSubject())) continue;
15!
432
            if (!predicate.equals(st.getPredicate())) continue;
18✔
433
            if (!(st.getObject() instanceof IRI obj)) continue;
27!
434
            out.add(obj);
12✔
435
        }
3✔
436
        return out;
6✔
437
    }
438

439
    // ---------------- gen:RoleInstantiation (and backcompat) ----------------
440

441
    private static void extractRoleInstantiation(Nanopub np, Context ctx, List<Statement> out) {
442
        // Find the assignment triple. Directionality (matches the publisher convention
443
        // used by gen:hasRegularProperty / gen:hasInverseProperty in role-definition
444
        // nanopubs):
445
        //   REGULAR: <agent> <predicate> <space>  → npa:regularProperty.
446
        //   INVERSE: <space> <predicate> <agent>  → npa:inverseProperty.
447
        // gen:hasAdmin is hardcoded INVERSE (space-centric: <space> hasAdmin <agent>).
448
        // The 14 backwards-compat predicates are classified in
449
        // {@link BackcompatRolePredicates#DIRECTIONS}. User-defined role predicates from
450
        // gen:SpaceMemberRole nanopubs aren't resolvable here without the role-declaration
451
        // registry; FIXME: the materializer in PR 2 should refine direction for the
452
        // typed-but-unknown-predicate case. For now we emit only triples whose predicate
453
        // we know the direction of.
454
        for (Statement st : np.getAssertion()) {
33!
455
            IRI predicate = st.getPredicate();
9✔
456
            BackcompatRolePredicates.Direction direction = directionFor(predicate);
9✔
457
            if (direction == null) continue;
6!
458
            if (!(st.getSubject() instanceof IRI subjIri)) continue;
27!
459
            if (!(st.getObject() instanceof IRI objIri)) continue;
27!
460

461
            IRI spaceSide;
462
            IRI agentSide;
463
            if (direction == BackcompatRolePredicates.Direction.REGULAR) {
9✔
464
                agentSide = subjIri;
6✔
465
                spaceSide = objIri;
9✔
466
            } else {
467
                spaceSide = subjIri;
6✔
468
                agentSide = objIri;
6✔
469
            }
470

471
            // Deduplicate against the (possibly already emitted) admin instantiation
472
            // from the gen:Space path — a single nanopub can be typed gen:Space AND
473
            // have a gen:hasAdmin triple that the backcompat list also catches. The
474
            // subject IRI is the same (derived from artifact code) and the payload
475
            // would conflict if re-emitted. Skip if we already have a RoleInstantiation
476
            // entry on this subject.
477
            IRI subject = SpacesVocab.forRoleInstantiation(ctx.artifactCode());
12✔
478
            Statement typeSt = vf.createStatement(subject, RDF.TYPE, GEN.ROLE_INSTANTIATION, GRAPH);
21✔
479
            if (out.contains(typeSt)) return;
12!
480

481
            out.add(typeSt);
12✔
482
            out.add(vf.createStatement(subject, SpacesVocab.FOR_SPACE, spaceSide, GRAPH));
27✔
483
            IRI directionPredicate = (direction == BackcompatRolePredicates.Direction.REGULAR)
9✔
484
                    ? SpacesVocab.REGULAR_PROPERTY
6✔
485
                    : SpacesVocab.INVERSE_PROPERTY;
6✔
486
            out.add(vf.createStatement(subject, directionPredicate, predicate, GRAPH));
27✔
487
            out.add(vf.createStatement(subject, SpacesVocab.FOR_AGENT, agentSide, GRAPH));
27✔
488
            out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
489
            addProvenance(subject, ctx, out);
12✔
490
            return;
3✔
491
        }
492
    }
×
493

494
    private static BackcompatRolePredicates.Direction directionFor(IRI predicate) {
495
        if (GEN.HAS_ADMIN.equals(predicate)) return BackcompatRolePredicates.Direction.INVERSE;
12!
496
        return BackcompatRolePredicates.DIRECTIONS.get(predicate);
15✔
497
    }
498

499
    // ---------------- gen:isSubSpaceOf (standalone path) ----------------
500

501
    /**
502
     * Standalone {@code gen:isSubSpaceOf} nanopub: every
503
     * {@code <childIri> gen:isSubSpaceOf <parentIri>} triple in the assertion emits one
504
     * {@code npa:SubSpaceDeclaration}. Multi-triple assertions are allowed; one entry
505
     * per pair. Self-loops ({@code <X> gen:isSubSpaceOf <X>}) are rejected.
506
     */
507
    private static void extractSubSpaceOf(Nanopub np, Context ctx, List<Statement> out) {
508
        for (Statement st : np.getAssertion()) {
33✔
509
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
15!
510
            if (!(st.getSubject() instanceof IRI childIri)) continue;
27!
511
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
512
            emitSubSpaceDeclaration(np, ctx, childIri, parentIri, out);
18✔
513
        }
3✔
514
    }
3✔
515

516
    /**
517
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
518
     * {@code <spaceIri> gen:isSubSpaceOf <parentIri>} triples (subject must equal the
519
     * Space IRI we're emitting an entry for, so the subspace declaration is bound to
520
     * this particular Space). Self-loops are rejected.
521
     */
522
    private static void emitSubSpaceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
523
                                                 List<Statement> out) {
524
        for (Statement st : np.getAssertion()) {
33✔
525
            if (!st.getPredicate().equals(GEN.IS_SUB_SPACE_OF)) continue;
18✔
526
            if (!spaceIri.equals(st.getSubject())) continue;
18✔
527
            if (!(st.getObject() instanceof IRI parentIri)) continue;
27!
528
            emitSubSpaceDeclaration(np, ctx, spaceIri, parentIri, out);
18✔
529
        }
3✔
530
    }
3✔
531

532
    /**
533
     * Emits one {@code npa:SubSpaceDeclaration} entry, keyed by
534
     * {@code (artifactCode, parentHash)} so a single nanopub can declare multiple
535
     * parents without subject collision. Self-loops are silently dropped.
536
     */
537
    private static void emitSubSpaceDeclaration(Nanopub np, Context ctx, IRI childIri,
538
                                                IRI parentIri, List<Statement> out) {
539
        if (childIri.equals(parentIri)) {
12✔
540
            log.debug("Ignoring self-loop sub-space declaration on {} in {}", childIri, np.getUri());
18✔
541
            return;
3✔
542
        }
543
        String parentHash = Utils.createHash(parentIri);
9✔
544
        IRI subject = SpacesVocab.forSubSpaceDeclaration(ctx.artifactCode(), parentHash);
15✔
545

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

553
        out.add(typeSt);
12✔
554
        out.add(vf.createStatement(subject, SpacesVocab.CHILD_SPACE, childIri, GRAPH));
27✔
555
        out.add(vf.createStatement(subject, SpacesVocab.PARENT_SPACE, parentIri, GRAPH));
27✔
556
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
557
        addProvenance(subject, ctx, out);
12✔
558
    }
3✔
559

560
    // ---------------- gen:isMaintainedBy ----------------
561

562
    /**
563
     * Standalone {@code gen:isMaintainedBy} nanopub: every
564
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triple in the assertion emits
565
     * one {@code npa:MaintainedResourceDeclaration}. Multi-triple assertions are
566
     * allowed; one entry per pair. Self-loops ({@code <X> gen:isMaintainedBy <X>}) are
567
     * rejected.
568
     */
569
    private static void extractIsMaintainedBy(Nanopub np, Context ctx, List<Statement> out) {
570
        for (Statement st : np.getAssertion()) {
33✔
571
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) continue;
18✔
572
            if (!(st.getSubject() instanceof IRI resourceIri)) continue;
27!
573
            if (!(st.getObject() instanceof IRI spaceIri)) continue;
27!
574
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
575
        }
3✔
576
    }
3✔
577

578
    /**
579
     * Embedded path: scan a {@code gen:Space} nanopub's assertion for
580
     * {@code <resourceIri> gen:isMaintainedBy <spaceIri>} triples (object must equal
581
     * the Space IRI we're emitting an entry for, so the maintained-resource
582
     * declaration is bound to this particular Space). Self-loops are rejected.
583
     */
584
    private static void emitMaintainedResourceDeclarations(Nanopub np, Context ctx, IRI spaceIri,
585
                                                           List<Statement> out) {
586
        for (Statement st : np.getAssertion()) {
33✔
587
            if (!st.getPredicate().equals(GEN.IS_MAINTAINED_BY)) continue;
18✔
588
            if (!spaceIri.equals(st.getObject())) continue;
18✔
589
            if (!(st.getSubject() instanceof IRI resourceIri)) continue;
27!
590
            emitMaintainedResourceDeclaration(np, ctx, resourceIri, spaceIri, out);
18✔
591
        }
3✔
592
    }
3✔
593

594
    /**
595
     * Emits one {@code npa:MaintainedResourceDeclaration} entry, keyed by
596
     * {@code (artifactCode, resourceHash)} so a single nanopub can declare multiple
597
     * maintained resources without subject collision. Self-loops are silently dropped.
598
     */
599
    private static void emitMaintainedResourceDeclaration(Nanopub np, Context ctx, IRI resourceIri,
600
                                                          IRI spaceIri, List<Statement> out) {
601
        if (resourceIri.equals(spaceIri)) {
12✔
602
            log.debug("Ignoring self-loop maintained-resource declaration on {} in {}",
15✔
603
                    resourceIri, np.getUri());
3✔
604
            return;
3✔
605
        }
606
        String resourceHash = Utils.createHash(resourceIri);
9✔
607
        IRI subject = SpacesVocab.forMaintainedResourceDeclaration(ctx.artifactCode(), resourceHash);
15✔
608

609
        // Idempotence: the embedded (gen:Space) and standalone (gen:isMaintainedBy)
610
        // paths can both fire on the same (np, resource, space) combination if a
611
        // gen:Space nanopub somehow ends up typed gen:isMaintainedBy as well. Skip if
612
        // we've already emitted the type triple for this subject.
613
        Statement typeSt = vf.createStatement(subject, RDF.TYPE,
21✔
614
                SpacesVocab.MAINTAINED_RESOURCE_DECLARATION, GRAPH);
615
        if (out.contains(typeSt)) return;
12!
616

617
        out.add(typeSt);
12✔
618
        out.add(vf.createStatement(subject, SpacesVocab.RESOURCE_IRI, resourceIri, GRAPH));
27✔
619
        out.add(vf.createStatement(subject, SpacesVocab.MAINTAINER_SPACE, spaceIri, GRAPH));
27✔
620
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
621
        addProvenance(subject, ctx, out);
12✔
622
    }
3✔
623

624
    // ---------------- owl:sameAs (space aliases) ----------------
625

626
    /**
627
     * Scans a {@code gen:Space} nanopub's assertion for
628
     * {@code <spaceIri> owl:sameAs <aliasIri>} triples (subject must equal the Space IRI
629
     * being emitted, so the alias declaration is bound to this particular Space) and emits
630
     * one {@code npa:SpaceAliasDeclaration} per {@code (spaceIri, aliasIri)} pair. The
631
     * Space IRI is the canonical side; the {@code owl:sameAs} object is the alias.
632
     * Self-aliases ({@code <X> owl:sameAs <X>}) are rejected.
633
     */
634
    private static void emitSpaceAliasDeclarations(Nanopub np, Context ctx, IRI spaceIri,
635
                                                   List<Statement> out) {
636
        for (Statement st : np.getAssertion()) {
33✔
637
            if (!st.getPredicate().equals(OWL.SAMEAS)) continue;
18✔
638
            if (!spaceIri.equals(st.getSubject())) continue;
18✔
639
            if (!(st.getObject() instanceof IRI aliasIri)) continue;
27!
640
            emitSpaceAliasDeclaration(np, ctx, spaceIri, aliasIri, out);
18✔
641
        }
3✔
642
    }
3✔
643

644
    /**
645
     * Emits one {@code npa:SpaceAliasDeclaration} entry, keyed by
646
     * {@code (artifactCode, aliasHash)} so a single nanopub can declare multiple aliases
647
     * without subject collision. Self-aliases are silently dropped.
648
     */
649
    private static void emitSpaceAliasDeclaration(Nanopub np, Context ctx, IRI canonicalIri,
650
                                                  IRI aliasIri, List<Statement> out) {
651
        if (canonicalIri.equals(aliasIri)) {
12✔
652
            log.debug("Ignoring self-alias declaration on {} in {}", canonicalIri, np.getUri());
18✔
653
            return;
3✔
654
        }
655
        String aliasHash = Utils.createHash(aliasIri);
9✔
656
        IRI subject = SpacesVocab.forSpaceAliasDeclaration(ctx.artifactCode(), aliasHash);
15✔
657

658
        // Idempotence: a single (np, canonical, alias) combination should produce one entry
659
        // even if emitSpaceAliasDeclarations somehow sees the triple twice.
660
        Statement typeSt = vf.createStatement(subject, RDF.TYPE, SpacesVocab.SPACE_ALIAS_DECLARATION, GRAPH);
21✔
661
        if (out.contains(typeSt)) return;
12!
662

663
        out.add(typeSt);
12✔
664
        out.add(vf.createStatement(subject, SpacesVocab.CANONICAL_SPACE, canonicalIri, GRAPH));
27✔
665
        out.add(vf.createStatement(subject, SpacesVocab.ALIAS_SPACE, aliasIri, GRAPH));
27✔
666
        out.add(vf.createStatement(subject, SpacesVocab.VIA_NANOPUB, np.getUri(), GRAPH));
30✔
667
        addProvenance(subject, ctx, out);
12✔
668
    }
3✔
669

670
    // ---------------- ID-prefix enumeration ----------------
671

672
    /**
673
     * Returns the immediate URL-path parent of a Space IRI, after normalisation,
674
     * for the URL-prefix sub-space fallback. Strips query / fragment / trailing
675
     * slash, then drops the last path segment after the {@code ://} scheme
676
     * separator. Returns at most one IRI; empty for inputs without a scheme
677
     * separator or without any path beyond the host.
678
     *
679
     * <p>Direct-parent-only semantics matches Nanodash's existing
680
     * {@code SpaceRepository.findSubspaces(...)} URL-regex behaviour. Multi-level
681
     * containment queries should use SPARQL property paths
682
     * ({@code <ancestor> npa:hasSubSpace+ ?descendant}) which walk the chain
683
     * transitively, so deeper descendants remain reachable as long as the
684
     * intermediate Spaces exist.
685
     *
686
     * <p>Examples:
687
     * <pre>
688
     *   https://example.org/a/b/c/space  →  [https://example.org/a/b/c]
689
     *   https://example.org/space        →  [https://example.org]   (single segment → host)
690
     *   https://example.org/x/           →  [https://example.org]   (trailing slash stripped)
691
     *   https://example.org/a/space?q=1  →  [https://example.org/a] (query stripped)
692
     *   https://example.org              →  []                       (no path to strip)
693
     * </pre>
694
     */
695
    static List<IRI> enumerateIdPrefixes(IRI spaceIri) {
696
        String s = spaceIri.stringValue();
9✔
697
        int hash = s.indexOf('#');
12✔
698
        if (hash >= 0) s = s.substring(0, hash);
21✔
699
        int qmark = s.indexOf('?');
12✔
700
        if (qmark >= 0) s = s.substring(0, qmark);
21✔
701
        while (s.endsWith("/")) s = s.substring(0, s.length() - 1);
39✔
702

703
        int schemeEnd = s.indexOf("://");
12✔
704
        if (schemeEnd < 0) return Collections.emptyList();
12✔
705
        int hostStart = schemeEnd + 3;
12✔
706
        int hostEnd = s.indexOf('/', hostStart);
15✔
707
        if (hostEnd < 0) return Collections.emptyList();   // host-only, nothing to strip
12✔
708

709
        // Drop the last path segment. If that strips us back to the host (single-
710
        // segment path), return the host-only IRI as the immediate parent.
711
        int lastSlash = s.lastIndexOf('/');
12✔
712
        String parent = (lastSlash <= hostEnd) ? s.substring(0, hostEnd) : s.substring(0, lastSlash);
39✔
713
        return List.of(vf.createIRI(parent));
15✔
714
    }
715

716
    // ---------------- shared helpers ----------------
717

718
    private static void addProvenance(Resource subject, Context ctx, List<Statement> out) {
719
        if (ctx.signedBy() != null) {
9!
720
            out.add(vf.createStatement(subject, NPX.SIGNED_BY, ctx.signedBy(), GRAPH));
30✔
721
        }
722
        if (ctx.pubkeyHash() != null) {
9!
723
            out.add(vf.createStatement(subject, SpacesVocab.PUBKEY_HASH,
27✔
724
                    vf.createLiteral(ctx.pubkeyHash()), GRAPH));
9✔
725
        }
726
        if (ctx.createdAt() != null) {
9!
727
            Literal ts = vf.createLiteral(ctx.createdAt());
15✔
728
            out.add(vf.createStatement(subject, DCTERMS.CREATED, ts, GRAPH));
27✔
729
        }
730
    }
3✔
731

732
    private static boolean anyMatch(Set<IRI> types, Set<IRI> candidates) {
733
        for (IRI c : candidates) {
30✔
734
            if (types.contains(c)) return true;
18✔
735
        }
3✔
736
        return false;
6✔
737
    }
738

739
    // ---------------- load-number stamping ----------------
740

741
    /**
742
     * Stamps {@code <thisNP> npa:hasLoadNumber <N>} on the given nanopub. Intended to
743
     * be called by the loader once per nanopub, in the same transaction as the
744
     * extraction writes. Also bumps {@code npa:thisRepo npa:currentLoadCounter <N>}
745
     * in the admin graph so the materializer's delta cycles know the horizon.
746
     *
747
     * @param npId        nanopub IRI
748
     * @param loadNumber  the load counter value
749
     * @return two statements: load-number stamp + current-load-counter value
750
     */
751
    public static List<Statement> loadCounterStatements(IRI npId, long loadNumber) {
752
        List<Statement> out = new ArrayList<>(2);
15✔
753
        Literal lit = vf.createLiteral(loadNumber);
12✔
754
        out.add(vf.createStatement(npId, NPA.HAS_LOAD_NUMBER, lit, NPA.GRAPH));
27✔
755
        out.add(vf.createStatement(NPA.THIS_REPO, SpacesVocab.CURRENT_LOAD_COUNTER, lit, NPA.GRAPH));
27✔
756
        return out;
6✔
757
    }
758

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