"use strict";
define([
"common/UrlUtilities",
"common/ValueUtilities",
"models/resourceMap/RDFGraph",
"models/resourceMap/ResourceMapCommon",
], (UrlUtilities, ValueUtilities, RDFGraph, ResourceMapCommon) => {
const TYPE_ASSERTION_CLASS_NAMES = new Set(["Data", "Program"]);
const {
addMapArrayValue,
addMapSetValue,
dedupeBy,
dedupeStrings,
isNonEmptyString,
normalizeText,
sortBy,
sortStrings,
} = ValueUtilities;
const { NAMESPACES, NS, PROV_EDGE_SPECS } = ResourceMapCommon;
const PROVONE_BASE_URI = NAMESPACES.PROVONE;
const EXECUTION_CLASS_URI = NS.PROVONE("Execution").value;
const PREDICATE_URIS = Object.freeze({
identifier: NS.DCTERMS("identifier").value,
rdfType: NS.RDF("type").value,
oreAggregates: NS.ORE("aggregates").value,
oreIsAggregatedBy: NS.ORE("isAggregatedBy").value,
citoDocuments: NS.CITO("documents").value,
citoIsDocumentedBy: NS.CITO("isDocumentedBy").value,
provAtLocation: NS.PROV("atLocation").value,
dcCreator: NS.DC("creator").value,
dctermsCreator: NS.DCTERMS("creator").value,
dcModified: NS.DC("modified").value,
dctermsModified: NS.DCTERMS("modified").value,
foafName: NS.FOAF("name").value,
provWasDerivedFrom: NS.PROV("wasDerivedFrom").value,
provWasGeneratedBy: NS.PROV("wasGeneratedBy").value,
provUsed: NS.PROV("used").value,
provWasInformedBy: NS.PROV("wasInformedBy").value,
provQualifiedAssociation: NS.PROV("qualifiedAssociation").value,
provHadPlan: NS.PROV("hadPlan").value,
});
/**
* Choose one existing RDF URI for summaries that use PIDs. Member editing
* does not rely on this choice; it reads every exact member URI and rejects
* ambiguous membership.
* @param {ResourceMap} resourceMap Resource Map being read
* @param {string} pid PID being resolved.
* @param {string[]} uris Candidate URIs.
* @param {Map<string, string>} identifierForUri Identifier lookup by URI.
* @param {boolean} isAggregated Whether the PID is a package member.
* @returns {string|null} Preferred URI.
*/
function buildPreferredUri(
resourceMap,
pid,
uris,
identifierForUri,
isAggregated,
) {
const candidateUris = sortStrings(uris);
if (!candidateUris.length) {
return null;
}
const directPidUri = candidateUris.find(
(uri) => normalizeText(uri) === pid,
);
if (
directPidUri &&
!isAggregated &&
ResourceMapCommon.isExternalDirectUriPid(pid)
) {
return directPidUri;
}
const canonicalUri = resourceMap.pidToUri(pid);
if (candidateUris.includes(canonicalUri)) {
return canonicalUri;
}
const exactResolveUri = candidateUris.find((uri) =>
ResourceMapCommon.isResolveUriForPid(uri, pid, {
allowFragment: false,
}),
);
if (exactResolveUri) {
return exactResolveUri;
}
const fragmentResolveUri = candidateUris.find((uri) =>
ResourceMapCommon.isResolveUriForPid(
UrlUtilities.stripFragment(uri),
pid,
{ allowFragment: false },
),
);
if (fragmentResolveUri) {
return fragmentResolveUri;
}
const identifierMatch = candidateUris.find(
(uri) => identifierForUri.get(uri) === pid,
);
return identifierMatch || directPidUri || candidateUris[0];
}
/**
* Sort one provenance statement into the collections needed to build the
* index.
* @param {string} predicateUri Predicate URI
* @param {object} scan Collected statements and lookup inputs
* @param {RDFGraphStatement} statement RDF statement
* @param {string} subjectKey Subject term key
* @param {string} objectKey Object term key
*/
function scanProvenanceStatement(
predicateUri,
scan,
statement,
subjectKey,
objectKey,
) {
const {
wasDerivedFromStatements,
wasGeneratedByStatements,
usedStatements,
wasInformedByStatements,
executionTermKeys,
managedLinkExecutionKeys,
associationNodesByExecutionKey,
planNodesByAssociationKey,
} = scan;
const { object } = statement;
switch (predicateUri) {
case PREDICATE_URIS.provWasDerivedFrom: {
wasDerivedFromStatements.push(statement);
break;
}
case PREDICATE_URIS.provWasGeneratedBy: {
const executionKey = PROV_EDGE_SPECS.generatedByProgram.dataFromObject
? subjectKey
: objectKey;
wasGeneratedByStatements.push(statement);
managedLinkExecutionKeys.add(executionKey);
executionTermKeys.add(executionKey);
break;
}
case PREDICATE_URIS.provUsed: {
const executionKey = PROV_EDGE_SPECS.usedByProgram.dataFromObject
? subjectKey
: objectKey;
usedStatements.push(statement);
managedLinkExecutionKeys.add(executionKey);
executionTermKeys.add(executionKey);
break;
}
case PREDICATE_URIS.provWasInformedBy: {
wasInformedByStatements.push(statement);
executionTermKeys.add(subjectKey);
executionTermKeys.add(objectKey);
managedLinkExecutionKeys.add(subjectKey);
managedLinkExecutionKeys.add(objectKey);
break;
}
case PREDICATE_URIS.provQualifiedAssociation: {
executionTermKeys.add(subjectKey);
addMapArrayValue(associationNodesByExecutionKey, subjectKey, object);
break;
}
case PREDICATE_URIS.provHadPlan: {
addMapArrayValue(planNodesByAssociationKey, subjectKey, object);
break;
}
default:
break;
}
}
/**
* Sort one RDF statement into the collections needed to build the index.
* @param {ResourceMap} resourceMap Resource Map being indexed
* @param {object} scan Collected statements and lookup inputs
* @param {RDFGraphStatement} statement RDF statement
*/
function scanResourceMapStatement(resourceMap, scan, statement) {
const {
identifierValuesByTermKey,
typeUrisByTermKey,
foafNamesByTermKey,
creatorStatements,
modifiedValues,
aggregateStatements,
isAggregatedByStatements,
documentationEdges,
atLocationStatements,
executionTermKeys,
} = scan;
const { subject, object } = statement;
const predicateUri = statement.predicate?.value;
const subjectKey = RDFGraph.buildTermKey(subject);
const objectKey = RDFGraph.buildTermKey(object);
switch (predicateUri) {
case PREDICATE_URIS.identifier: {
const identifierValue = normalizeText(RDFGraph.getLiteralValue(object));
if (isNonEmptyString(identifierValue)) {
addMapArrayValue(
identifierValuesByTermKey,
subjectKey,
identifierValue,
);
}
break;
}
case PREDICATE_URIS.rdfType: {
if (RDFGraph.isNamedNode(object)) {
addMapArrayValue(typeUrisByTermKey, subjectKey, object.value);
if (object.value === EXECUTION_CLASS_URI) {
executionTermKeys.add(subjectKey);
}
}
break;
}
case PREDICATE_URIS.foafName: {
const nameValue = normalizeText(RDFGraph.getLiteralValue(object));
if (isNonEmptyString(nameValue)) {
addMapArrayValue(foafNamesByTermKey, subjectKey, nameValue);
}
break;
}
case PREDICATE_URIS.oreAggregates: {
aggregateStatements.push(statement);
break;
}
case PREDICATE_URIS.oreIsAggregatedBy: {
isAggregatedByStatements.push(statement);
break;
}
case PREDICATE_URIS.citoDocuments: {
documentationEdges.push({
metadataNode: subject,
dataNode: object,
});
break;
}
case PREDICATE_URIS.citoIsDocumentedBy: {
documentationEdges.push({
metadataNode: object,
dataNode: subject,
});
break;
}
case PREDICATE_URIS.provAtLocation: {
const rawPath = RDFGraph.getLiteralValue(object);
if (isNonEmptyString(rawPath)) {
atLocationStatements.push({
subjectNode: subject,
path: rawPath,
});
}
break;
}
case PREDICATE_URIS.dcCreator:
case PREDICATE_URIS.dctermsCreator: {
if (
RDFGraph.isNamedNode(subject) &&
subject.value === resourceMap.resourceMapUri
) {
creatorStatements.push(statement);
}
break;
}
case PREDICATE_URIS.dcModified:
case PREDICATE_URIS.dctermsModified: {
if (
RDFGraph.isNamedNode(subject) &&
subject.value === resourceMap.resourceMapUri
) {
const modifiedValue = normalizeText(RDFGraph.getLiteralValue(object));
if (isNonEmptyString(modifiedValue)) {
modifiedValues.push(modifiedValue);
}
}
break;
}
case PREDICATE_URIS.provWasDerivedFrom:
case PREDICATE_URIS.provWasGeneratedBy:
case PREDICATE_URIS.provUsed:
case PREDICATE_URIS.provWasInformedBy:
case PREDICATE_URIS.provQualifiedAssociation:
case PREDICATE_URIS.provHadPlan: {
scanProvenanceStatement(
predicateUri,
scan,
statement,
subjectKey,
objectKey,
);
break;
}
default:
break;
}
}
/**
* Read every graph statement once and group the values needed for later
* package and provenance lookups.
* @param {ResourceMap} resourceMap Resource Map being indexed
* @returns {object} Collected statements and lookup inputs
*/
function scanStatements(resourceMap) {
const baseIndex = resourceMap.graph.createIndex();
const scan = {
termByKey: baseIndex.termByKey,
namedNodeByUri: baseIndex.namedNodeByUri,
identifierValuesByTermKey: new Map(),
typeUrisByTermKey: new Map(),
foafNamesByTermKey: new Map(),
creatorStatements: [],
modifiedValues: [],
aggregateStatements: [],
isAggregatedByStatements: [],
documentationEdges: [],
atLocationStatements: [],
wasDerivedFromStatements: [],
wasGeneratedByStatements: [],
usedStatements: [],
wasInformedByStatements: [],
executionTermKeys: new Set(),
managedLinkExecutionKeys: new Set(),
associationNodesByExecutionKey: new Map(),
planNodesByAssociationKey: new Map(),
};
resourceMap.graph.getStatements().forEach((statement) => {
scanResourceMapStatement(resourceMap, scan, statement);
});
return scan;
}
/**
* Prefer an identifier matching the node's URI, or use its only identifier.
* @param {ResourceMap} resourceMap Resource Map whose endpoints are used
* @param {NamedNode|BlankNode} node Node declaring the identifiers
* @param {string[]} identifiers Distinct nonempty identifier values
* @returns {string|null} Preferred identifier, or null when ambiguous
*/
function selectNodeIdentifier(resourceMap, node, identifiers) {
const isNamedNode = RDFGraph.isNamedNode(node);
const uriPid = isNamedNode
? ResourceMapCommon.recoverPidFromUri(node.value, {
allowBareValue: true,
resolveServiceUrl: resourceMap.resolveServiceUrl,
objectServiceUrl: resourceMap.objectServiceUrl,
})
: null;
return (
identifiers.find(
(identifier) =>
isNamedNode &&
(ResourceMapCommon.identifierMatchesPid(
identifier,
uriPid,
resourceMap,
) ||
identifier === node.value),
) || (identifiers.length === 1 ? identifiers[0] : null)
);
}
/**
* Build lookups between RDF nodes, declared identifiers, and DataONE PIDs.
* @param {ResourceMap} resourceMap Resource Map being indexed
* @param {object} scan Collected statements and lookup inputs
* @returns {object} Identity lookups and shared PID resolver
*/
function buildIdentityIndex(resourceMap, scan) {
const { termByKey, namedNodeByUri, identifierValuesByTermKey } = scan;
const identifiersByTermKey = new Map();
const identifierForTermKey = new Map();
identifierValuesByTermKey.forEach((values, key) => {
const identifiers = dedupeStrings(values);
identifiersByTermKey.set(key, identifiers);
const node = termByKey.get(key);
const preferredIdentifier = selectNodeIdentifier(
resourceMap,
node,
identifiers,
);
identifierForTermKey.set(key, preferredIdentifier);
});
const identifierForUri = new Map();
identifierForTermKey.forEach((identifierValue, key) => {
const node = termByKey.get(key);
if (RDFGraph.isNamedNode(node) && isNonEmptyString(identifierValue)) {
// Read the expected PID from a configured endpoint URL.
// For the resource map, use the PID supplied by the caller.
const expectedPid =
node.value === resourceMap.resourceMapUri
? resourceMap.resourceMapPid
: ResourceMapCommon.configuredEndpointPids(
node.value,
resourceMap,
)[0];
// PID lookups collapse configured DataONE URL aliases, while the raw
// identifier indexes above preserve imported RDF literals exactly.
identifierForUri.set(
node.value,
ResourceMapCommon.managedIdentifierValuePid(
identifierValue,
expectedPid,
resourceMap,
),
);
}
});
const pidByTermKey = new Map();
const urisByPid = new Map();
/**
* Resolve a PID using only lookups collected for this index.
* @param {NamedNode|BlankNode|Literal|null|undefined} node Candidate node
* @returns {string|null} Resolved PID
*/
const resolvePidForNode = (node) => {
const key = RDFGraph.buildTermKey(node);
if (pidByTermKey.has(key)) {
return pidByTermKey.get(key);
}
// Prefer an exact node's unambiguous declared identifier. If several
// identifiers all disagree with a known URL PID, leave the node
// unresolved so normalization cannot add the URL PID and hide the
// contradiction that validation needs to report.
const identifiers = identifiersByTermKey.get(key) || [];
const hasAmbiguousDeclaredIdentifiers =
identifiers.length > 1 && !identifierForTermKey.get(key);
const pid =
RDFGraph.isNamedNode(node) && !hasAmbiguousDeclaredIdentifiers
? ResourceMapCommon.recoverPidFromUri(node.value, {
identifierForUri,
allowBareValue: true,
resolveServiceUrl: resourceMap.resolveServiceUrl,
objectServiceUrl: resourceMap.objectServiceUrl,
})
: null;
pidByTermKey.set(key, pid || null);
if (isNonEmptyString(pid) && RDFGraph.isNamedNode(node)) {
addMapSetValue(urisByPid, pid, node.value);
}
return pid || null;
};
namedNodeByUri.forEach((node) => {
resolvePidForNode(node);
});
return {
identifiersByTermKey,
identifierForTermKey,
identifierForUri,
pidByTermKey,
urisByPid,
resolvePidForNode,
};
}
/**
* Add each member's stored locations and read the Resource Map creator and
* modified date.
* @param {Array<{pid: string, uri: string}>} memberDescriptors Members
* without atLocation metadata
* @param {object} scan Collected statements and lookup inputs
* @returns {object} Member metadata and descriptive resource map values
*/
function buildPackageMetadata(memberDescriptors, scan) {
const {
foafNamesByTermKey,
creatorStatements,
modifiedValues,
atLocationStatements,
} = scan;
const atLocationsByMemberUri = new Map();
atLocationStatements.forEach(({ subjectNode, path }) => {
if (RDFGraph.isNamedNode(subjectNode)) {
addMapArrayValue(atLocationsByMemberUri, subjectNode.value, path);
}
});
const descriptorsWithLocations = memberDescriptors.map((descriptor) => {
const atLocations = sortStrings([
...new Set(atLocationsByMemberUri.get(descriptor.uri) || []),
]);
return {
...descriptor,
atLocations,
};
});
const memberDescriptorByPid = new Map(
descriptorsWithLocations.map((descriptor) => [
descriptor.pid,
descriptor,
]),
);
const namedCreator = creatorStatements.find(({ object }) => {
const names = foafNamesByTermKey.get(RDFGraph.buildTermKey(object));
return !RDFGraph.isLiteral(object) && names?.length;
})?.object;
const creatorName = namedCreator
? foafNamesByTermKey.get(RDFGraph.buildTermKey(namedCreator))[0]
: creatorStatements
.map(({ object }) => normalizeText(RDFGraph.getLiteralValue(object)))
.find(isNonEmptyString) || null;
return {
memberDescriptors: descriptorsWithLocations,
memberDescriptorByPid,
creatorName,
modified: modifiedValues[0] || null,
};
}
/**
* Build package member, metadata documentation, creator, and modified date
* lookups.
* @param {ResourceMap} resourceMap Resource Map being indexed
* @param {object} scan Collected statements and lookup inputs
* @param {object} identity Identity lookups and shared PID resolver
* @returns {object} Package lookups and summaries
*/
function buildPackageProjection(resourceMap, scan, identity) {
const {
termByKey,
namedNodeByUri,
aggregateStatements,
isAggregatedByStatements,
documentationEdges,
} = scan;
const {
identifiersByTermKey,
identifierForUri,
urisByPid,
resolvePidForNode,
} = identity;
const memberTermKeys = new Set();
const aggregationKey = RDFGraph.buildTermKey(
RDFGraph.createNamedNode(resourceMap.aggregationUri),
);
aggregateStatements.forEach((statement) => {
if (
RDFGraph.isNamedNode(statement.object) &&
ResourceMapCommon.isAbsoluteIri(statement.object.value) &&
RDFGraph.buildTermKey(statement.subject) === aggregationKey
) {
memberTermKeys.add(RDFGraph.buildTermKey(statement.object));
}
});
isAggregatedByStatements.forEach((statement) => {
if (
RDFGraph.isNamedNode(statement.subject) &&
ResourceMapCommon.isAbsoluteIri(statement.subject.value) &&
RDFGraph.buildTermKey(statement.object) === aggregationKey
) {
memberTermKeys.add(RDFGraph.buildTermKey(statement.subject));
}
});
// The UI groups records by PID, but RDF still distinguishes exact URIs.
// Keep every URI linked to the selected aggregation so duplicate member
// identities can be reported instead of silently choosing one.
const membershipDescriptors = Array.from(memberTermKeys)
.map((key) => {
const node = termByKey.get(key);
const pid = resolvePidForNode(node);
return isNonEmptyString(pid) ? { pid, uri: node.value } : null;
})
.filter(Boolean);
const memberPids = dedupeStrings(
membershipDescriptors.map(({ pid }) => pid),
);
const memberSet = new Set(memberPids);
const memberUrisByPid = new Map();
membershipDescriptors.forEach(({ pid, uri }) => {
addMapSetValue(memberUrisByPid, pid, uri);
});
const preferredUriByPid = new Map(
Array.from(urisByPid.entries()).map(([pid, values]) => [
pid,
buildPreferredUri(
resourceMap,
pid,
Array.from(memberUrisByPid.get(pid) || values),
identifierForUri,
memberSet.has(pid),
),
]),
);
const memberDescriptors = memberPids.map((pid) => ({
pid,
uri: preferredUriByPid.get(pid),
}));
const nodesByIdentifier = new Map();
identifiersByTermKey.forEach((identifiers, key) => {
const node = termByKey.get(key);
identifiers.forEach((identifierValue) => {
addMapArrayValue(nodesByIdentifier, identifierValue, node);
});
});
namedNodeByUri.forEach((node, uri) => {
addMapArrayValue(nodesByIdentifier, normalizeText(uri), node);
});
const documentationLinksByKey = new Map();
documentationEdges.forEach(({ metadataNode, dataNode }) => {
const metadataPid = resolvePidForNode(metadataNode);
const dataPid = resolvePidForNode(dataNode);
if (!metadataPid || !dataPid) return;
const link = { metadataPid, dataPid };
const key = RDFGraph.buildKey([metadataPid, dataPid]);
const metadataIsMember = memberTermKeys.has(
RDFGraph.buildTermKey(metadataNode),
);
const dataIsMember = memberTermKeys.has(RDFGraph.buildTermKey(dataNode));
if (metadataIsMember && dataIsMember) {
documentationLinksByKey.set(key, link);
}
});
const documentationLinks = Array.from(documentationLinksByKey.values());
const isDocumentedByByPid = new Map();
const documentsByPid = new Map();
documentationLinks.forEach(({ metadataPid, dataPid }) => {
addMapSetValue(isDocumentedByByPid, dataPid, metadataPid);
addMapSetValue(documentsByPid, metadataPid, dataPid);
});
const packageMetadata = buildPackageMetadata(memberDescriptors, scan);
return {
preferredUriByPid,
nodesByIdentifier,
...packageMetadata,
membershipDescriptors,
memberUrisByPid,
memberPids,
memberSet,
documentationLinks,
isDocumentedByByPid,
documentsByPid,
};
}
/**
* Determine which PIDs represent data and which represent programs, using
* both explicit types and how nodes participate in provenance relationships.
* @param {object} scan Collected statements and lookup inputs
* @param {object} identity Identity lookups and shared PID resolver
* @returns {object} Data and program role lookups and summaries
*/
function buildRoleProjection(scan, identity) {
const {
termByKey,
typeUrisByTermKey,
wasDerivedFromStatements,
wasGeneratedByStatements,
usedStatements,
managedLinkExecutionKeys,
associationNodesByExecutionKey,
planNodesByAssociationKey,
} = scan;
const { resolvePidForNode } = identity;
// Public type summaries include only the supported Data and Program
// classes. Execution RDF remains unchanged and appears in execution
// summaries instead.
const explicitTypeAssertions = Array.from(
typeUrisByTermKey.entries(),
).flatMap(([subjectKey, classUris]) => {
const pid = resolvePidForNode(termByKey.get(subjectKey));
if (!pid) {
return [];
}
return dedupeStrings(classUris)
.filter((classUri) => classUri.startsWith(PROVONE_BASE_URI))
.map((classUri) => ({
pid,
className: classUri.slice(PROVONE_BASE_URI.length),
}))
.filter(({ className }) => TYPE_ASSERTION_CLASS_NAMES.has(className));
});
const rolePidSets = {
Data: new Set(),
Program: new Set(),
};
wasDerivedFromStatements.forEach((statement) => {
[statement.subject, statement.object]
.map(resolvePidForNode)
.filter(Boolean)
.forEach((pid) => rolePidSets.Data.add(pid));
});
[
[wasGeneratedByStatements, PROV_EDGE_SPECS.generatedByProgram],
[usedStatements, PROV_EDGE_SPECS.usedByProgram],
].forEach(([statements, { dataFromObject }]) => {
statements.forEach((statement) => {
const pid = resolvePidForNode(
dataFromObject ? statement.object : statement.subject,
);
if (pid) {
rolePidSets.Data.add(pid);
}
});
});
associationNodesByExecutionKey.forEach((associationNodes, executionKey) => {
const executionNode = termByKey.get(executionKey);
if (
(!RDFGraph.isNamedNode(executionNode) &&
!RDFGraph.isBlankNode(executionNode)) ||
!managedLinkExecutionKeys.has(executionKey)
) {
return;
}
associationNodes.forEach((associationNode) => {
const planNodes =
planNodesByAssociationKey.get(
RDFGraph.buildTermKey(associationNode),
) || [];
planNodes
.map(resolvePidForNode)
.filter(Boolean)
.forEach((pid) => rolePidSets.Program.add(pid));
});
});
// Provenance relationships can imply Data and Program types. Derive those
// types for summaries instead of storing generated statements in the graph.
// Serialization adds them when needed, while explicit imported types remain
// unchanged.
const typeAssertions = dedupeBy(
[
...explicitTypeAssertions,
...Array.from(rolePidSets.Data, (pid) => ({
pid,
className: "Data",
})),
...Array.from(rolePidSets.Program, (pid) => ({
pid,
className: "Program",
})),
],
({ pid, className }) => RDFGraph.buildKey([pid, className]),
);
return {
typeAssertions,
rolePidSets,
};
}
/**
* Build a summary for each program run and a lookup from program PID to its
* runs.
* @param {object} scan Collected statements and lookup inputs
* @param {object} identity Identity lookups and shared PID resolver
* @returns {object} Program run lookups and summaries
*/
function buildExecutionProjection(scan, identity) {
const {
termByKey,
typeUrisByTermKey,
executionTermKeys,
managedLinkExecutionKeys,
associationNodesByExecutionKey,
planNodesByAssociationKey,
} = scan;
const { identifiersByTermKey, identifierForTermKey, resolvePidForNode } =
identity;
const executionSummariesByKey = new Map();
const executionNodes = Array.from(executionTermKeys)
.map((key) => termByKey.get(key))
.filter(
(node) => RDFGraph.isNamedNode(node) || RDFGraph.isBlankNode(node),
);
executionNodes.forEach((executionNode) => {
const executionKey = RDFGraph.buildTermKey(executionNode);
const associations = RDFGraph.dedupeTerms(
associationNodesByExecutionKey.get(executionKey) || [],
).map((associationNode) => {
const associationKey = RDFGraph.buildTermKey(associationNode);
return {
node: associationNode,
planNodes: RDFGraph.dedupeTerms(
planNodesByAssociationKey.get(associationKey) || [],
),
};
});
const programPids = dedupeStrings(
associations
.flatMap(({ planNodes }) => planNodes)
.map(resolvePidForNode)
.filter(isNonEmptyString),
);
const identifierLiteral = identifierForTermKey.get(executionKey) || null;
const identifierValues = identifiersByTermKey.get(executionKey) || [];
const identifier =
identifierLiteral ||
(RDFGraph.isNamedNode(executionNode)
? normalizeText(executionNode.value)
: null);
executionSummariesByKey.set(executionKey, {
node: executionNode,
executionKey,
isExecution: dedupeStrings(
typeUrisByTermKey.get(executionKey) || [],
).includes(EXECUTION_CLASS_URI),
identifier,
hasIdentifierLiteral: identifierValues.length > 0,
hasAmbiguousIdentifier:
identifierValues.length > 1 && !identifierLiteral,
associations,
programPids,
hasManagedLinks: managedLinkExecutionKeys.has(executionKey),
});
});
const executionNodesByProgramPid = new Map();
executionSummariesByKey.forEach((summary) => {
summary.programPids.forEach((programPid) => {
addMapArrayValue(executionNodesByProgramPid, programPid, summary.node);
});
});
return {
executionNodes,
executionSummariesByKey,
executionNodesByProgramPid,
};
}
/**
* Convert `wasInformedBy` links between program runs into current and
* previous program pairs.
* @param {RDFGraphStatement[]} wasInformedByStatements Informed by
* statements
* @param {Map<string, object>} executionSummariesByKey Execution summaries
* by term key
* @returns {ProgramLineageRelationship[]} Program lineage relationships
*/
function buildWasInformedByPrograms(
wasInformedByStatements,
executionSummariesByKey,
) {
return dedupeBy(
wasInformedByStatements.flatMap((statement) => {
const currentExecution = executionSummariesByKey.get(
RDFGraph.buildTermKey(statement.subject),
);
const previousExecution = executionSummariesByKey.get(
RDFGraph.buildTermKey(statement.object),
);
if (!currentExecution || !previousExecution) {
return [];
}
return currentExecution.programPids.flatMap((programPid) =>
previousExecution.programPids.map((previousProgramPid) => ({
programPid,
previousProgramPid,
executionId: normalizeText(currentExecution.identifier),
previousExecutionId: normalizeText(previousExecution.identifier),
})),
);
}),
({ programPid, previousProgramPid, executionId, previousExecutionId }) =>
RDFGraph.buildKey([
programPid,
previousProgramPid,
executionId,
previousExecutionId,
]),
);
}
/**
* Convert RDF provenance statements into plain relationship summaries keyed
* by PID.
* @param {object} scan Collected statements and lookup inputs
* @param {object} identity Identity lookups and shared PID resolver
* @param {object} executionProjection Program run lookups
* @returns {object} Provenance relationship summaries
*/
function buildProvenanceProjection(scan, identity, executionProjection) {
const {
wasDerivedFromStatements,
wasGeneratedByStatements,
usedStatements,
wasInformedByStatements,
} = scan;
const { resolvePidForNode } = identity;
const { executionSummariesByKey } = executionProjection;
const wasDerivedFrom = dedupeBy(
wasDerivedFromStatements
.map((statement) => {
const derivedPid = resolvePidForNode(statement.subject);
const sourcePid = resolvePidForNode(statement.object);
if (!derivedPid || !sourcePid) {
return null;
}
return { derivedPid, sourcePid };
})
.filter(Boolean),
({ derivedPid, sourcePid }) => RDFGraph.buildKey([derivedPid, sourcePid]),
);
const projectExecutionProgramRelationships = (
statementsToProject,
dataFromObject,
) =>
dedupeBy(
statementsToProject.flatMap((statement) => {
const dataNode = dataFromObject
? statement.object
: statement.subject;
const executionNode = dataFromObject
? statement.subject
: statement.object;
const dataPid = resolvePidForNode(dataNode);
const executionSummary = executionSummariesByKey.get(
RDFGraph.buildTermKey(executionNode),
);
if (!dataPid || !executionSummary) {
return [];
}
// The editor works at program level, but these detailed summaries
// keep execution identity so imported runs remain distinct when RDF
// is serialized, parsed again, or cleaned up.
return executionSummary.programPids.map((programPid) => ({
dataPid,
programPid,
executionId: normalizeText(executionSummary.identifier),
...(executionSummary.identifier
? {}
: { executionKey: executionSummary.executionKey }),
}));
}),
({ dataPid, programPid, executionId, executionKey }) =>
RDFGraph.buildKey([dataPid, programPid, executionId, executionKey]),
);
// prov:wasGeneratedBy stores the data PID in the subject position;
// prov:used stores it in the object position.
const generatedByPrograms = projectExecutionProgramRelationships(
wasGeneratedByStatements,
PROV_EDGE_SPECS.generatedByProgram.dataFromObject,
);
const usedByPrograms = projectExecutionProgramRelationships(
usedStatements,
PROV_EDGE_SPECS.usedByProgram.dataFromObject,
);
const wasInformedByPrograms = buildWasInformedByPrograms(
wasInformedByStatements,
executionSummariesByKey,
);
return {
wasDerivedFrom,
generatedByPrograms,
usedByPrograms,
wasInformedByPrograms,
};
}
/**
* Build one plain package member record from the graph index.
* @param {object} index Derived graph index
* @param {{pid: string, uri: string, atLocations: string[]}} descriptor
* Indexed member descriptor
* @returns {ResourceMapMember} Member summary
*/
function buildMemberSummary(index, descriptor) {
return {
pid: descriptor.pid,
uri: descriptor.uri,
isDocumentedBy: sortStrings([
...(index.isDocumentedByByPid.get(descriptor.pid) || []),
]),
documents: sortStrings([
...(index.documentsByPid.get(descriptor.pid) || []),
]),
atLocations: [...descriptor.atLocations],
};
}
/**
* Cache plain lookups and summaries derived from a Resource Map's RDF graph.
* This class never changes the graph. ResourceMap clears the cache after each
* graph edit, and the next read rebuilds it. Keeping these derived values
* avoids repeating the same graph scans during reads and validation.
* @class ResourceMapState
* @classcategory Models/ResourceMap
* @since 2.39.0
*/
class ResourceMapState {
/**
* Create derived state for one ResourceMap.
* @param {object} options State options
* @param {ResourceMap} options.resourceMap Resource Map to index
*/
constructor({ resourceMap } = {}) {
this.resourceMap = resourceMap;
this.cachedSummary = null;
this.graphIndex = null;
}
/** Clear all derived state after a graph mutation. */
invalidate() {
this.graphIndex = null;
this.cachedSummary = null;
}
/**
* Clone an indexed record list so callers can mutate the result safely.
* @param {string} field Graph index field holding plain record objects
* @returns {object[]} Shallow cloned records
*/
cloneRecords(field) {
return this.getIndex()[field].map((record) => ({ ...record }));
}
/** @returns {object} Lazily built graph index. */
getIndex() {
if (!this.graphIndex) {
if (this.resourceMap.isGraphMutating()) {
throw new Error(
"ResourceMapState must be built before starting a graph mutation",
);
}
this.graphIndex = this.buildGraphIndex();
}
return this.graphIndex;
}
/**
* Build all cached lookups used by ResourceMap reads and validation.
* @returns {object} Derived graph index
*/
buildGraphIndex() {
const { resourceMap } = this;
const scan = scanStatements(resourceMap);
const identity = buildIdentityIndex(resourceMap, scan);
const executionProjection = buildExecutionProjection(scan, identity);
return {
identifiersByTermKey: identity.identifiersByTermKey,
pidByTermKey: identity.pidByTermKey,
...buildPackageProjection(resourceMap, scan, identity),
...buildRoleProjection(scan, identity),
...executionProjection,
...buildProvenanceProjection(scan, identity, executionProjection),
};
}
/**
* Return a cached plain package summary, building it on the first read.
* @returns {ResourceMapSummary} Cloned package summary.
*/
getSummary() {
if (this.cachedSummary) {
return ValueUtilities.deepClone(this.cachedSummary);
}
const index = this.getIndex();
const members = index.memberDescriptors.map((descriptor) =>
buildMemberSummary(index, descriptor),
);
const summary = {
resourceMapPid: this.resourceMap.resourceMapPid,
resourceMapUri: this.resourceMap.resourceMapUri,
aggregationUri: this.resourceMap.aggregationUri,
resolveServiceUrl: this.resourceMap.resolveServiceUrl,
members: sortBy(members, (member) => member.pid),
documentationLinks: ResourceMapCommon.sortByFields(
index.documentationLinks,
["metadataPid", "dataPid"],
),
creatorName: index.creatorName,
modified: index.modified,
provenance: this.resourceMap.provenance.toJSON(),
};
this.cachedSummary = summary;
return ValueUtilities.deepClone(summary);
}
/** @returns {ResourceMapValidationContext} Shared validation inputs. */
createValidationContext() {
return {
resourceMap: this.resourceMap,
ns: this.resourceMap.ns,
resourceMapNode: RDFGraph.createNamedNode(
this.resourceMap.resourceMapUri,
),
aggregationNode: RDFGraph.createNamedNode(
this.resourceMap.aggregationUri,
),
};
}
/**
* Return one member summary from indexed graph state.
* @param {string} pid Member PID.
* @returns {ResourceMapMember|null} Member summary when present.
*/
getMember(pid) {
const normalizedPid = normalizeText(pid);
if (!isNonEmptyString(normalizedPid)) {
return null;
}
const index = this.getIndex();
const descriptor = index.memberDescriptorByPid.get(normalizedPid);
return descriptor ? buildMemberSummary(index, descriptor) : null;
}
/**
* Test whether a PID belongs to the package.
* @param {string} pid Candidate PID.
* @returns {boolean} Whether the PID is a member.
*/
hasMember(pid) {
const normalizedPid = normalizeText(pid);
return (
isNonEmptyString(normalizedPid) &&
this.getIndex().memberSet.has(normalizedPid)
);
}
/** @returns {string[]} Aggregated member PIDs. */
getMemberPids() {
return [...this.getIndex().memberPids];
}
/** @returns {Array<{pid: string, uri: string}>} Exact membership nodes. */
getMemberDescriptors() {
return this.cloneRecords("membershipDescriptors");
}
/**
* Return every exact RDF URI used by package members that claim one PID.
* @param {string} pid Member PID
* @returns {string[]} Sorted package member URIs
*/
getMemberUris(pid) {
const normalizedPid = normalizeText(pid);
if (!isNonEmptyString(normalizedPid)) return [];
return sortStrings(
Array.from(this.getIndex().memberUrisByPid.get(normalizedPid) || []),
);
}
/** @returns {ResMapDocLink[]} Cloned documentation links. */
getDocumentationLinks() {
return this.cloneRecords("documentationLinks");
}
/**
* Find RDF resources that declare a given identifier.
* @param {string} identifier Identifier to resolve.
* @returns {Array<NamedNode|BlankNode>} Matching RDF resource nodes.
*/
findNodesByIdentifier(identifier) {
return RDFGraph.dedupeTerms(
this.getIndex().nodesByIdentifier.get(normalizeText(identifier)) || [],
);
}
/**
* Resolve a node's PID from the current graph, including identifiers added
* earlier in a grouped edit. Cached summaries remain snapshots of the edit's
* starting state.
* @param {NamedNode|BlankNode|Literal} node RDF node
* @returns {string|null} PID when resolvable
*/
pidFromNode(node) {
if (!RDFGraph.isNamedNode(node)) return null;
const { resourceMap } = this;
const identifierForUri = new Map();
// Also check identifiers on the URI without its fragment.
const uris = new Set([
node.value,
UrlUtilities.stripFragment(node.value),
]);
let hasAmbiguousIdentifiers = false;
uris.forEach((uri) => {
const subject = RDFGraph.createNamedNode(uri);
const identifiers = dedupeStrings(
resourceMap.graph
.findStatements({ subject, predicate: NS.DCTERMS("identifier") })
.map(({ object }) => RDFGraph.getLiteralValue(object)),
);
const identifier = selectNodeIdentifier(
resourceMap,
subject,
identifiers,
);
if (uri === node.value && identifiers.length > 1 && !identifier) {
hasAmbiguousIdentifiers = true;
return;
}
if (identifier) {
const expectedPid =
uri === resourceMap.resourceMapUri
? resourceMap.resourceMapPid
: ResourceMapCommon.configuredEndpointPids(uri, resourceMap)[0];
identifierForUri.set(
uri,
ResourceMapCommon.managedIdentifierValuePid(
identifier,
expectedPid,
resourceMap,
),
);
}
});
if (hasAmbiguousIdentifiers) return null;
return ResourceMapCommon.recoverPidFromUri(node.value, {
identifierForUri,
allowBareValue: true,
resolveServiceUrl: resourceMap.resolveServiceUrl,
objectServiceUrl: resourceMap.objectServiceUrl,
});
}
/**
* Return the preferred existing RDF URI for a PID.
* @param {string} pid PID to resolve.
* @returns {string|null} Preferred URI.
*/
findNodeUriForPid(pid) {
const normalizedPid = normalizeText(pid);
if (!isNonEmptyString(normalizedPid)) {
return null;
}
return this.getIndex().preferredUriByPid.get(normalizedPid) || null;
}
/**
* Test whether a node has an expected identifier.
* @param {NamedNode|BlankNode|Literal} node RDF node.
* @param {string} pid Expected PID.
* @returns {boolean} Whether the identifier matches.
*/
nodeHasIdentifier(node, pid) {
const normalizedPid = normalizeText(pid);
return (
isNonEmptyString(normalizedPid) &&
(
this.getIndex().identifiersByTermKey.get(
RDFGraph.buildTermKey(node),
) || []
).includes(normalizedPid)
);
}
/** @returns {TypeAssertion[]} Data and Program classifications. */
getTypeAssertions() {
return this.cloneRecords("typeAssertions");
}
/**
* Return PIDs classified as one PROVONE type, such as `Data` or `Program`.
* @param {string} className PROVONE class name.
* @returns {Set<string>} Matching PIDs.
*/
getRolePidSet(className) {
return new Set(this.getIndex().rolePidSets[className] || []);
}
/** @returns {Array<NamedNode|BlankNode>} RDF nodes representing program runs. */
getExecutionNodes() {
return [...this.getIndex().executionNodes];
}
/**
* Return a safe copy of one program run summary.
* @param {NamedNode|BlankNode} executionNode Execution node.
* @returns {object|null} Execution summary.
*/
getExecutionSummary(executionNode) {
const summary = this.getIndex().executionSummariesByKey.get(
RDFGraph.buildTermKey(executionNode),
);
if (!summary) {
return null;
}
return {
...summary,
associations: summary.associations.map((association) => ({
...association,
planNodes: [...association.planNodes],
})),
programPids: [...summary.programPids],
};
}
/**
* Find the recorded runs of one program.
* @param {string} programPid Program PID.
* @returns {Array<NamedNode|BlankNode>} Matching executions.
*/
getExecutionNodesForProgram(programPid) {
return [
...(this.getIndex().executionNodesByProgramPid.get(
normalizeText(programPid),
) || []),
];
}
/** @returns {WasDerivedFromRelationship[]} Data source relationships. */
getWasDerivedFromLinks() {
return this.cloneRecords("wasDerivedFrom");
}
/** @returns {ExecutionProgramRelationship[]} Links from programs to data they produced. */
getGeneratedByPrograms() {
return this.cloneRecords("generatedByPrograms");
}
/** @returns {ExecutionProgramRelationship[]} Links from programs to data they consumed. */
getUsedByPrograms() {
return this.cloneRecords("usedByPrograms");
}
/** @returns {ProgramLineageRelationship[]} Current/previous program links. */
getWasInformedByPrograms() {
return this.cloneRecords("wasInformedByPrograms");
}
}
return ResourceMapState;
});