"use strict";
/**
* Create and remove the program run relationships used by provenance.
*
* The public Provenance API says that a program produced or consumed data. RDF
* represents that history with an Execution node for the individual program
* run. The Execution links to an Association, and the Association links to the
* program through a Plan. This module manages those extra RDF nodes.
*
* MetacatUI reuses a program's run only when there is exactly one safe choice.
* Multiple or ambiguous imported runs remain unchanged and read only. When the
* final managed relationship is removed, the minimal run and association nodes
* created by MetacatUI are removed too; imported nodes with extra information
* are preserved.
*
* During a grouped edit, execution selection reads the current graph so each
* operation sees the program links created or removed by earlier operations.
* @since 2.39.0
*/
define([
"common/ValueUtilities",
"models/resourceMap/RDFGraph",
"models/resourceMap/ResourceMapCommon",
"models/resourceMap/ProvenanceValidation",
], (ValueUtilities, RDFGraph, ResourceMapCommon, ProvenanceValidation) => {
const { requireNonEmptyString, makeUUID } = ValueUtilities;
const { NS, ResourceMapConflictError } = ResourceMapCommon;
/**
* Create the error returned when there is no single program run that can be
* edited safely.
* @param {string} programPid Program selected by the caller
* @returns {ResourceMapConflictError} Read only provenance conflict
*/
function programProvenanceReadOnlyError(programPid) {
return new ResourceMapConflictError(
`Program "${programPid}" is read only. Only programs with no run or one unambiguous run can be edited here.`,
{
code: "programProvenanceReadOnly",
details: { programPid },
},
);
}
/**
* Find the current program run, including changes made earlier in the same
* grouped edit. Plan URIs may differ while identifying the same program PID.
* @param {Provenance} provenance Provenance instance whose graph is inspected
* @param {{pid: string, node: NamedNode}} programMember Exact program package
* member
* @returns {NamedNode|BlankNode|null} Current execution node when found
* @throws {Error} When multiple executions make editing unsafe
*/
function resolveExecution(provenance, programMember) {
const { graph, graphState } = provenance.resourceMap;
const { pid: programPid } = programMember;
const associationNodes = graph
.findStatements({ predicate: NS.PROV("hadPlan") })
.filter(({ object }) => graphState.pidFromNode(object) === programPid)
.map(({ subject }) => subject);
const executionNodes = RDFGraph.dedupeTerms(
associationNodes.flatMap((associationNode) =>
graph
.findStatements({
predicate: NS.PROV("qualifiedAssociation"),
object: associationNode,
})
.map(({ subject }) => subject),
),
).filter(
(executionNode) =>
RDFGraph.isNamedNode(executionNode) ||
RDFGraph.isBlankNode(executionNode),
);
if (executionNodes.length > 1) {
throw programProvenanceReadOnlyError(programPid);
}
return executionNodes[0] || null;
}
/**
* Label a node as a program run and add its identifier when either statement
* is missing.
* @param {Provenance} provenance Provenance instance whose graph is updated
* @param {NamedNode|BlankNode} executionNode Execution node to update
* @param {object} [inspection] Existing execution summary
*/
function ensureExecutionIdentity(provenance, executionNode, inspection = {}) {
const { graph } = provenance.resourceMap;
if (!inspection.isExecution) {
graph.addStatementIfMissing({
subject: executionNode,
predicate: NS.RDF("type"),
object: NS.PROVONE("Execution"),
});
}
if (
RDFGraph.isNamedNode(executionNode) &&
!inspection.hasIdentifierLiteral
) {
const executionId = inspection.identifier || executionNode.value;
graph.addStatementIfMissing({
subject: executionNode,
predicate: NS.DCTERMS("identifier"),
object: RDFGraph.createLiteral(executionId, NS.XSD("string")),
});
}
}
/**
* Connect a program run to the program that was executed. PROV stores this as
* an Association linked to a Plan that identifies the program. Reuse an
* existing Association and Plan, and add a program link only when none exists.
* @param {Provenance} provenance Provenance instance whose graph is updated.
* @param {NamedNode|BlankNode} executionNode Execution node to update.
* @param {{pid: string, node: NamedNode}} programMember Exact program package
* member that should be linked
* @throws {Error} When existing associations make program provenance unsafe
* to edit
*/
function ensureAssociationForExecution(
provenance,
executionNode,
programMember,
) {
const { graph, graphState } = provenance.resourceMap;
const { node: programNode, pid: programPid } = programMember;
const associationNodes = RDFGraph.dedupeTerms(
graph
.findStatements({
subject: executionNode,
predicate: NS.PROV("qualifiedAssociation"),
})
.map(({ object }) => object),
);
if (associationNodes.length > 1) {
throw programProvenanceReadOnlyError(programPid);
}
const associationNode = associationNodes[0] || RDFGraph.createBlankNode();
const planNodes = RDFGraph.dedupeTerms(
graph
.findStatements({
subject: associationNode,
predicate: NS.PROV("hadPlan"),
})
.map(({ object }) => object),
);
if (planNodes.length > 1) {
throw programProvenanceReadOnlyError(programPid);
}
const [existingPlanNode] = planNodes;
if (
existingPlanNode &&
graphState.pidFromNode(existingPlanNode) !== programPid
) {
throw programProvenanceReadOnlyError(programPid);
}
graph.addStatementIfMissing({
subject: executionNode,
predicate: NS.PROV("qualifiedAssociation"),
object: associationNode,
});
if (!planNodes.length) {
graph.addStatementIfMissing({
subject: associationNode,
predicate: NS.PROV("hadPlan"),
object: programNode,
});
}
}
/**
* Update the program runs belonging to one provenance graph.
* @class ProvenanceExecutionMutation
* @classcategory Models/ResourceMap
* @since 2.39.0
*/
class ProvenanceExecutionMutation {
/**
* Create a mutation helper for one provenance model.
* @param {object} options Mutation options
* @param {Provenance} options.provenance Provenance instance being updated
*/
constructor({ provenance } = {}) {
this.provenance = provenance;
}
/**
* Find or create the single program run used to connect a program package
* member to data.
* @param {{pid: string, node: NamedNode}} programMember Exact program
* package member whose run is needed
* @returns {NamedNode|BlankNode} Existing or newly created execution node
* Intended to be called inside `Provenance.mutateGraph()`
* @throws {ResourceMapConflictError} When the program's execution graph is
* not safely editable
*/
ensureExecutionForProgram(programMember) {
const { provenance } = this;
const normalizedProgramPid = programMember.pid;
const { graphState } = provenance.resourceMap;
if (
!ProvenanceValidation.isProgramExecutionEditable(
provenance,
normalizedProgramPid,
)
) {
throw programProvenanceReadOnlyError(normalizedProgramPid);
}
const executionNode = resolveExecution(provenance, programMember);
if (executionNode) {
const inspection = graphState.getExecutionSummary(executionNode);
if (inspection) {
ensureExecutionIdentity(provenance, executionNode, inspection);
}
ensureAssociationForExecution(provenance, executionNode, programMember);
return executionNode;
}
const executionId = makeUUID();
const newExecutionNode = RDFGraph.createNamedNode(executionId);
ensureExecutionIdentity(provenance, newExecutionNode);
ensureAssociationForExecution(
provenance,
newExecutionNode,
programMember,
);
return newExecutionNode;
}
/**
* Record that a program run produced or consumed a data object.
* @param {object} options Relationship options
* @param {string} options.dataPid Data PID in the relationship
* @param {string} options.programPid Program PID in the relationship
* @param {string} options.predicate PROV relationship name
* @param {boolean} [options.dataFromObject] Whether the data is on the
* object side of the RDF statement
* @returns {Provenance} Updated provenance instance
* @throws {ResourceMapConflictError} When the program's execution graph is
* not safely editable
*/
addExecutionProgramRelationship(options) {
const { provenance } = this;
const programMember = provenance.resourceMap.resolveMemberNode(
options.programPid,
{
required: true,
message: "Program PID required",
},
);
return provenance.mutateGraph(() => {
const { dataPid, predicate, dataFromObject = false } = options;
const dataNode = provenance.ensurePidNode(dataPid, {
message: "Data PID required",
});
const executionNode = this.ensureExecutionForProgram(programMember);
provenance.resourceMap.graph.addStatementIfMissing({
subject: dataFromObject ? executionNode : dataNode,
predicate: NS.PROV(predicate),
object: dataFromObject ? dataNode : executionNode,
});
});
}
/**
* Remove the record that a program run produced or consumed a data object.
* @param {object} options Relationship options
* @param {string} options.dataPid Data PID in the relationship
* @param {string} options.programPid Program PID in the relationship
* @param {string} options.predicate PROV relationship name
* @param {boolean} [options.dataFromObject] Whether the data is on the
* object side of the RDF statement
* @returns {Provenance} Updated provenance instance
* @throws {Error} When identifiers are invalid, member resolution fails,
* or the program provenance is read-only
*/
removeExecutionProgramRelationship({
dataPid,
programPid,
predicate,
dataFromObject = false,
}) {
const { provenance } = this;
const normalizedDataPid = requireNonEmptyString(
dataPid,
"Data PID required",
);
const programMember = provenance.resourceMap.resolveMemberNode(
programPid,
{
required: true,
message: "Program PID required",
},
);
const normalizedProgramPid = programMember.pid;
const { graph, graphState } = provenance.resourceMap;
if (
!ProvenanceValidation.isProgramExecutionEditable(
provenance,
normalizedProgramPid,
)
) {
throw programProvenanceReadOnlyError(normalizedProgramPid);
}
const executionNode = resolveExecution(provenance, programMember);
if (!executionNode) return provenance;
const statementsToRemove = graph
.findStatements({ predicate: NS.PROV(predicate) })
.filter((statement) => {
const dataNode = dataFromObject
? statement.object
: statement.subject;
const statementExecutionNode = dataFromObject
? statement.subject
: statement.object;
return (
RDFGraph.buildTermKey(statementExecutionNode) ===
RDFGraph.buildTermKey(executionNode) &&
graphState.pidFromNode(dataNode) === normalizedDataPid
);
});
if (!statementsToRemove.length) return provenance;
return provenance.mutateGraph(() => {
// One chart relationship summarizes every RDF edge for this data PID
// and program PID. Removing it must remove every matching edge.
statementsToRemove.forEach((statement) => {
graph.removeStatement(statement);
});
provenance.removeStandaloneExecutionScaffolds([executionNode]);
});
}
/**
* Add a missing program run type or identifier only when existing
* relationships make the node's role certain. Ambiguous imported structures
* are left untouched.
* @param {object[]} executionInspections Precomputed execution summaries
*/
normalizeExecutionGraph(executionInspections) {
const { provenance } = this;
executionInspections.forEach((inspection) => {
const { node: executionNode } = inspection;
if (!inspection.hasManagedLinks) {
return;
}
ensureExecutionIdentity(provenance, executionNode, inspection);
});
}
/**
* Remove an unnamed Association node after its program link is deleted, but
* only when it contains no other imported information.
* @param {Array<BlankNode|NamedNode>} associationNodes Changed associations
*/
removeDanglingQualifiedAssociations(associationNodes) {
const { graph } = this.provenance.resourceMap;
RDFGraph.dedupeTerms(associationNodes)
.filter((associationNode) => {
if (!RDFGraph.isBlankNode(associationNode)) return false;
// The canonical Association type is scaffold metadata, not
// enriched RDF that should retain a planless blank association.
return graph
.findStatements({ subject: associationNode })
.every(
({ predicate, object }) =>
RDFGraph.buildTermKey(predicate) ===
RDFGraph.buildTermKey(NS.RDF("type")) &&
RDFGraph.buildTermKey(object) ===
RDFGraph.buildTermKey(NS.PROV("Association")),
);
})
.forEach((associationNode) => {
graph.removeStatementsMatching({
predicate: NS.PROV("qualifiedAssociation"),
object: associationNode,
});
if (!graph.hasStatement({ object: associationNode })) {
graph.removeNodeReferences(associationNode);
}
});
}
}
return ProvenanceExecutionMutation;
});