diff --git a/docs/03-apis/api-v2/ontology-information.md b/docs/03-apis/api-v2/ontology-information.md index b552c69ffa..44b9ee19f2 100644 --- a/docs/03-apis/api-v2/ontology-information.md +++ b/docs/03-apis/api-v2/ontology-information.md @@ -1452,8 +1452,7 @@ the property definition, submit the request without those predicates. ### Adding Cardinalities to a Class -If the class is used in data or if it -has a subclass, it is not allowed to add cardinalities `owl:minCardinality` greater than 0 or `owl:cardinality 1` to the class. +If the class (or any of its sub-classes) is used in data, it is not allowed to add cardinalities `owl:minCardinality` greater than 0 or `owl:cardinality 1` to the class. ``` HTTP POST to http://host/v2/ontologies/cardinalities diff --git a/webapi/src/main/scala/org/knora/webapi/responders/Responder.scala b/webapi/src/main/scala/org/knora/webapi/responders/Responder.scala index 9739bdf7ca..a8a11343a1 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/Responder.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/Responder.scala @@ -131,6 +131,31 @@ abstract class Responder(responderData: ResponderData) extends LazyLogging { } yield isEntityUsedResponse.results.bindings.nonEmpty + /** + * Checks whether an instance of a class (or any ob its sub-classes) exists + * + * @param classIri the IRI of the class. + * + * @return `true` if the class is used. + */ + protected def isClassUsedInData( + classIri: SmartIri + ): Future[Boolean] = + for { + isClassUsedInDataSparql <- Future( + org.knora.webapi.messages.twirl.queries.sparql.v2.txt + .isClassUsedInData( + triplestore = settings.triplestoreType, + classIri = classIri + ) + .toString() + ) + + isClassUsedInDataResponse: SparqlSelectResult <- (storeManager ? SparqlSelectRequest(isClassUsedInDataSparql)) + .mapTo[SparqlSelectResult] + + } yield isClassUsedInDataResponse.results.bindings.nonEmpty + /** * Throws an exception if an entity is used in the triplestore. * @@ -153,6 +178,24 @@ abstract class Responder(responderData: ResponderData) extends LazyLogging { } } yield () + /** + * Throws an exception if a class is used in data. + * + * @param classIri the IRI of the class. + * @param errorFun a function that throws an exception. It will be called if the class is used. + */ + protected def throwIfClassIsUsedInData( + classIri: SmartIri, + errorFun: => Nothing + ): Future[Unit] = + for { + classIsUsed: Boolean <- isClassUsedInData(classIri) + + _ = if (classIsUsed) { + errorFun + } + } yield () + /** * Checks whether an entity with the provided custom IRI exists in the triplestore, if yes, throws an exception. * If no custom IRI was given, creates a random unused IRI. diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala index 58aede63a4..113dca26fe 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/OntologyResponderV2.scala @@ -1348,15 +1348,13 @@ class OntologyResponderV2(responderData: ResponderData) extends Responder(respon } _ <- hasCardinality match { - // If there is, check that the class isn't used in data, and that it has no subclasses. + // If there is, check that the class isn't used in data. case Some((propIri: SmartIri, cardinality: KnoraCardinalityInfo)) => - throwIfEntityIsUsed( - entityIri = internalClassIri, + throwIfClassIsUsedInData( + classIri = internalClassIri, errorFun = throw BadRequestException( - s"Cardinality ${cardinality.toString} for $propIri cannot be added to class ${addCardinalitiesRequest.classInfoContent.classIri}, because it is used in data or has a subclass" - ), - ignoreKnoraConstraints = - true // It's OK if a property refers to the class via knora-base:subjectClassConstraint or knora-base:objectClassConstraint. + s"Cardinality ${cardinality.toString} for $propIri cannot be added to class ${addCardinalitiesRequest.classInfoContent.classIri}, because it is used in data" + ) ) case None => Future.successful(()) } diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/isClassUsedInData.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/isClassUsedInData.scala.txt new file mode 100644 index 0000000000..c2edca328f --- /dev/null +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/isClassUsedInData.scala.txt @@ -0,0 +1,33 @@ +@* + * Copyright © 2021 Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + *@ + +@import org.knora.webapi.messages.SmartIri + +@* + * Checks if an instance of a class exists (if yes, that means the class is used in data) + * + * @param triplestore the name of the triplestore being used. + * @param classIri the IRI of the class. + *@ +@(triplestore: String, + classIri: SmartIri) + +PREFIX rdf: +PREFIX rdfs: + +SELECT DISTINCT ?isUsed + +WHERE { + BIND(IRI("@classIri") AS ?class) + BIND(true AS ?isUsed) + + { + ?resourceInstance rdf:type ?class + } + UNION{ + ?subClass rdfs:subClassOf* ?class . + ?resourceInstance rdf:type ?subClass + } +} diff --git a/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala b/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala index ba9f03a656..23134ff11e 100644 --- a/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala +++ b/webapi/src/test/scala/org/knora/webapi/responders/v2/OntologyResponderV2Spec.scala @@ -3480,7 +3480,7 @@ class OntologyResponderV2Spec extends CoreSpec() with ImplicitSender { } } - "not add a cardinality=1 to the class anything:Nothing, because it has a subclass" in { + "add a cardinality=1 to the class anything:Nothing which has a subclass" in { val classIri = AnythingOntologyIri.makeEntityIri("Nothing") val classInfoContent = ClassInfoContentV2( @@ -3505,9 +3505,34 @@ class OntologyResponderV2Spec extends CoreSpec() with ImplicitSender { requestingUser = anythingAdminUser ) - expectMsgPF(timeout) { case msg: akka.actor.Status.Failure => - if (printErrorMessages) println(msg.cause.getMessage) - msg.cause.isInstanceOf[BadRequestException] should ===(true) + expectMsgPF(timeout) { case msg: ReadOntologyV2 => + val externalOntology = msg.toOntologySchema(ApiV2Complex) + assert(externalOntology.classes.size == 1) + + val metadata = externalOntology.ontologyMetadata + val newAnythingLastModDate = metadata.lastModificationDate.getOrElse( + throw AssertionException(s"${metadata.ontologyIri} has no last modification date") + ) + assert(newAnythingLastModDate.isAfter(anythingLastModDate)) + anythingLastModDate = newAnythingLastModDate + } + + responderManager ! DeleteCardinalitiesFromClassRequestV2( + classInfoContent = classInfoContent, + lastModificationDate = anythingLastModDate, + apiRequestID = UUID.randomUUID, + featureFactoryConfig = defaultFeatureFactoryConfig, + requestingUser = anythingAdminUser + ) + + expectMsgPF(timeout) { case msg: ReadOntologyV2 => + val externalOntology = msg.toOntologySchema(ApiV2Complex) + val metadata = externalOntology.ontologyMetadata + val newAnythingLastModDate = metadata.lastModificationDate.getOrElse( + throw AssertionException(s"${metadata.ontologyIri} has no last modification date") + ) + assert(newAnythingLastModDate.isAfter(anythingLastModDate)) + anythingLastModDate = newAnythingLastModDate } }